From 6f760098c047d20ef85eb51bcbc996228377932b Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 17 Feb 2026 12:30:16 +0100 Subject: [PATCH 01/23] chore: first draft --- packages/appkit/src/index.ts | 2 +- packages/appkit/src/plugins/files/defaults.ts | 35 +++ packages/appkit/src/plugins/files/files.ts | 41 ++++ packages/appkit/src/plugins/files/helpers.ts | 12 ++ packages/appkit/src/plugins/files/index.ts | 3 + packages/appkit/src/plugins/files/lib.ts | 200 ++++++++++++++++++ .../appkit/src/plugins/files/manifest.json | 22 ++ packages/appkit/src/plugins/files/manifest.ts | 17 ++ packages/appkit/src/plugins/files/types.ts | 22 ++ packages/appkit/src/plugins/index.ts | 1 + packages/appkit/tsdown.config.ts | 4 + 11 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 packages/appkit/src/plugins/files/defaults.ts create mode 100644 packages/appkit/src/plugins/files/files.ts create mode 100644 packages/appkit/src/plugins/files/helpers.ts create mode 100644 packages/appkit/src/plugins/files/index.ts create mode 100644 packages/appkit/src/plugins/files/lib.ts create mode 100644 packages/appkit/src/plugins/files/manifest.json create mode 100644 packages/appkit/src/plugins/files/manifest.ts create mode 100644 packages/appkit/src/plugins/files/types.ts diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 8ba528ef..ec729d33 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -46,7 +46,7 @@ export { } from "./errors"; // Plugin authoring export { Plugin, toPlugin } from "./plugin"; -export { analytics, server } from "./plugins"; +export { analytics, files, server } from "./plugins"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/files/defaults.ts b/packages/appkit/src/plugins/files/defaults.ts new file mode 100644 index 00000000..e717b333 --- /dev/null +++ b/packages/appkit/src/plugins/files/defaults.ts @@ -0,0 +1,35 @@ +import type { PluginExecuteConfig } from "shared"; + +// // TODO: Tune defaults based on actual file operation characteristics +// export const filesDefaults: PluginExecuteConfig = { +// cache: { +// enabled: false, +// ttl: 0, +// }, +// retry: { +// enabled: true, +// initialDelay: 1000, +// attempts: 3, +// }, +// timeout: 30000, +// }; + +export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".json": "application/json", + ".xml": "application/xml", + ".html": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".txt": "text/plain", + ".md": "text/markdown", + ".csv": "text/csv", + ".pdf": "application/pdf", +}); diff --git a/packages/appkit/src/plugins/files/files.ts b/packages/appkit/src/plugins/files/files.ts new file mode 100644 index 00000000..65412dbe --- /dev/null +++ b/packages/appkit/src/plugins/files/files.ts @@ -0,0 +1,41 @@ +import type { IAppRouter } from "shared"; +import { Plugin, toPlugin } from "../../plugin"; +import { filesManifest } from "./manifest"; +import type { IFilesConfig } from "./types"; + +export class FilesPlugin extends Plugin { + name = "files"; + + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = filesManifest; + + protected static description = "Files plugin for Databricks file operations"; + protected declare config: IFilesConfig; + + constructor(config: IFilesConfig) { + super(config); + this.config = config; + // TODO: Initialize file operation services + } + + injectRoutes(_router: IAppRouter) { + // TODO: Register file operation routes + } + + async shutdown(): Promise { + this.streamManager.abortAll(); + } + + exports() { + // TODO: Return public API methods + return {}; + } +} + +/** + * @internal + */ +export const files = toPlugin( + FilesPlugin, + "files", +); diff --git a/packages/appkit/src/plugins/files/helpers.ts b/packages/appkit/src/plugins/files/helpers.ts new file mode 100644 index 00000000..cfe042e1 --- /dev/null +++ b/packages/appkit/src/plugins/files/helpers.ts @@ -0,0 +1,12 @@ +import { EXTENSION_CONTENT_TYPES } from "./defaults"; + +export function contentTypeFromPath( + filePath: string, + reported?: string, +): string { + if (reported && reported !== "application/octet-stream") { + return reported; + } + const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); + return EXTENSION_CONTENT_TYPES[ext] ?? reported ?? "application/octet-stream"; +} diff --git a/packages/appkit/src/plugins/files/index.ts b/packages/appkit/src/plugins/files/index.ts new file mode 100644 index 00000000..eaaa19b1 --- /dev/null +++ b/packages/appkit/src/plugins/files/index.ts @@ -0,0 +1,3 @@ +export * from "./files"; +export * from "./manifest"; +export * from "./types"; diff --git a/packages/appkit/src/plugins/files/lib.ts b/packages/appkit/src/plugins/files/lib.ts new file mode 100644 index 00000000..f221f47f --- /dev/null +++ b/packages/appkit/src/plugins/files/lib.ts @@ -0,0 +1,200 @@ +import { ApiError, WorkspaceClient } from "@databricks/sdk-experimental"; +import { contentTypeFromPath } from "./helpers"; +import type { + DirectoryEntry, + DownloadResponse, + FileMetadata, + FilePreview, +} from "./types"; + +export class FilesClient { + private client: WorkspaceClient; + private defaultVolume: string | undefined; + + constructor({ + defaultVolume, + client, + }: { + defaultVolume?: string; + client?: WorkspaceClient; + }) { + this.client = client ?? new WorkspaceClient({}); + if (defaultVolume) { + this.defaultVolume = defaultVolume; + } + } + + private resolvePath(filePath: string): string { + if (filePath.startsWith("/")) { + return filePath; + } + if (!this.defaultVolume) { + throw new Error( + "Cannot resolve relative path: no default volume set. Use an absolute path or set a default volume.", + ); + } + return `${this.defaultVolume}/${filePath}`; + } + + volume(volumePath: string): FilesClient { + return new FilesClient({ defaultVolume: volumePath, client: this.client }); + } + + async list(directoryPath?: string): Promise { + const resolvedPath = directoryPath + ? this.resolvePath(directoryPath) + : this.defaultVolume; + if (!resolvedPath) { + throw new Error("No directory path provided and no default volume set."); + } + const entries: DirectoryEntry[] = []; + for await (const entry of this.client.files.listDirectoryContents({ + directory_path: resolvedPath, + })) { + entries.push(entry); + } + return entries; + } + + async read(filePath: string): Promise { + const response = await this.download(filePath); + if (!response.contents) { + return ""; + } + const reader = response.contents.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; + } + + async download(filePath: string): Promise { + return this.client.files.download({ + file_path: this.resolvePath(filePath), + }); + } + + async exists(filePath: string): Promise { + try { + await this.metadata(filePath); + return true; + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + return false; + } + throw error; + } + } + + async metadata(filePath: string): Promise { + const response = await this.client.files.getMetadata({ + file_path: this.resolvePath(filePath), + }); + return { + contentLength: response["content-length"], + contentType: contentTypeFromPath(filePath, response["content-type"]), + lastModified: response["last-modified"], + }; + } + + async upload( + filePath: string, + contents: ReadableStream | Buffer | string, + options?: { overwrite?: boolean }, + ): Promise { + // Workaround: The SDK's files.upload() has two bugs: + // 1. It ignores the `contents` field (sets body to undefined) + // 2. apiClient.request() checks `instanceof` against its own ReadableStream + // subclass, so standard ReadableStream instances get JSON.stringified to "{}" + // Bypass both by calling the REST API directly with SDK-provided auth. + let body: Buffer | string; + if (typeof contents === "string") { + body = contents; + } else if (Buffer.isBuffer(contents)) { + body = contents; + } else { + // ReadableStream → Buffer + const reader = (contents as ReadableStream).getReader(); + const chunks: Uint8Array[] = []; + let result = await reader.read(); + while (!result.done) { + chunks.push(result.value); + result = await reader.read(); + } + body = Buffer.concat(chunks); + } + + const resolvedPath = this.resolvePath(filePath); + const overwrite = options?.overwrite ?? true; + const url = new URL( + `/api/2.0/fs/files${resolvedPath}`, + this.client.config.host, + ); + url.searchParams.set("overwrite", String(overwrite)); + + const headers = new Headers({ "Content-Type": "application/octet-stream" }); + await this.client.config.authenticate(headers); + + const res = await fetch(url.toString(), { + method: "PUT", + headers, + body, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Upload failed (${res.status}): ${text}`); + } + } + + async delete(filePath: string): Promise { + await this.client.files.delete({ + file_path: this.resolvePath(filePath), + }); + } + + async preview(filePath: string): Promise { + const meta = await this.metadata(filePath); + const isText = + meta.contentType?.startsWith("text/") || + meta.contentType === "application/json" || + meta.contentType === "application/xml" || + false; + const isImage = meta.contentType?.startsWith("image/") || false; + + if (!isText) { + return { ...meta, textPreview: null, isText: false, isImage }; + } + + const response = await this.client.files.download({ + file_path: this.resolvePath(filePath), + }); + if (!response.contents) { + return { ...meta, textPreview: "", isText: true, isImage: false }; + } + + const reader = response.contents.getReader(); + const decoder = new TextDecoder(); + let preview = ""; + const maxBytes = 1024; + + while (preview.length < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + preview += decoder.decode(value, { stream: true }); + } + preview += decoder.decode(); + await reader.cancel(); + + if (preview.length > maxBytes) { + preview = preview.slice(0, maxBytes); + } + + return { ...meta, textPreview: preview, isText: true, isImage: false }; + } +} diff --git a/packages/appkit/src/plugins/files/manifest.json b/packages/appkit/src/plugins/files/manifest.json new file mode 100644 index 00000000..c17811bd --- /dev/null +++ b/packages/appkit/src/plugins/files/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "files", + "displayName": "Files Plugin", + "description": "File operations against Databricks Volumes and Unity Catalog", + "resources": { + "required": [], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "number", + "default": 30000, + "description": "File operation timeout in milliseconds" + } + } + } + } +} diff --git a/packages/appkit/src/plugins/files/manifest.ts b/packages/appkit/src/plugins/files/manifest.ts new file mode 100644 index 00000000..59ce59ce --- /dev/null +++ b/packages/appkit/src/plugins/files/manifest.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PluginManifest } from "../../registry"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Files plugin manifest. + * + * @remarks + * The source of truth for this manifest is `manifest.json` in the same directory. + * This file loads the JSON and exports it with proper TypeScript typing. + */ +export const filesManifest: PluginManifest = JSON.parse( + readFileSync(join(__dirname, "manifest.json"), "utf-8"), +) as PluginManifest; diff --git a/packages/appkit/src/plugins/files/types.ts b/packages/appkit/src/plugins/files/types.ts new file mode 100644 index 00000000..41755497 --- /dev/null +++ b/packages/appkit/src/plugins/files/types.ts @@ -0,0 +1,22 @@ +// import type { BasePluginConfig } from "shared"; +import type { files } from "@databricks/sdk-experimental"; + +// export interface IFilesConfig extends BasePluginConfig { +// timeout?: number; +// } + +// TODO: Add request/response types for file operations +export type DirectoryEntry = files.DirectoryEntry; +export type DownloadResponse = files.DownloadResponse; + +export interface FileMetadata { + contentLength: number | undefined; + contentType: string | undefined; + lastModified: string | undefined; +} + +export interface FilePreview extends FileMetadata { + textPreview: string | null; + isText: boolean; + isImage: boolean; +} diff --git a/packages/appkit/src/plugins/index.ts b/packages/appkit/src/plugins/index.ts index aba6f26b..ccc652ff 100644 --- a/packages/appkit/src/plugins/index.ts +++ b/packages/appkit/src/plugins/index.ts @@ -1,2 +1,3 @@ export * from "./analytics"; +export * from "./files"; export * from "./server"; diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 2472c084..dd1fad3c 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -42,6 +42,10 @@ export default defineConfig([ from: "src/plugins/analytics/manifest.json", to: "dist/plugins/analytics/manifest.json", }, + { + from: "src/plugins/files/manifest.json", + to: "dist/plugins/files/manifest.json", + }, { from: "src/plugins/server/manifest.json", to: "dist/plugins/server/manifest.json", From ce82860d449d644f7aed0d5e29247e9527867332 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 17 Feb 2026 14:09:15 +0100 Subject: [PATCH 02/23] chore: add files route to dev-playground --- .../client/src/appKitTypes.d.ts | 71 +- .../client/src/routeTree.gen.ts | 21 + .../client/src/routes/__root.tsx | 8 + .../client/src/routes/files.route.tsx | 618 ++++++++++++++++++ .../client/src/routes/index.tsx | 18 + apps/dev-playground/server/index.ts | 228 ++++++- .../appkit/Function.contentTypeFromPath.md | 16 + docs/docs/api/appkit/index.md | 1 + docs/docs/api/appkit/typedoc-sidebar.ts | 5 + .../appkit/src/connectors/files/client.ts | 345 ++++++++++ packages/appkit/src/index.ts | 1 + packages/appkit/src/plugins/files/files.ts | 40 +- packages/appkit/src/plugins/files/index.ts | 1 + packages/appkit/src/plugins/files/lib.ts | 6 + packages/appkit/src/plugins/files/types.ts | 9 +- 15 files changed, 1317 insertions(+), 71 deletions(-) create mode 100644 apps/dev-playground/client/src/routes/files.route.tsx create mode 100644 docs/docs/api/appkit/Function.contentTypeFromPath.md create mode 100644 packages/appkit/src/connectors/files/client.ts diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index 0e0ae0b0..049fae9f 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -14,46 +14,28 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType STRING */ - day_of_week: string; - /** @sqlType DECIMAL(35,2) */ - spend: number; + ; }>; }; apps_list: { name: "apps_list"; parameters: Record; result: Array<{ - /** @sqlType STRING */ - id: string; - /** @sqlType STRING */ - name: string; - /** @sqlType STRING */ - creator: string; - /** @sqlType STRING */ - tags: string; - /** @sqlType DECIMAL(38,6) */ - totalSpend: number; - /** @sqlType DATE */ - createdAt: string; + ; }>; }; cost_recommendations: { name: "cost_recommendations"; parameters: Record; result: Array<{ - /** @sqlType INT */ - dummy: number; + ; }>; }; example: { name: "example"; parameters: Record; result: Array<{ - /** @sqlType BOOLEAN */ - "(1 = 1)": boolean; + ; }>; }; spend_data: { @@ -73,12 +55,7 @@ declare module "@databricks/appkit-ui/react" { creator: SQLStringMarker; }; result: Array<{ - /** @sqlType STRING */ - group_key: string; - /** @sqlType TIMESTAMP */ - aggregation_period: string; - /** @sqlType DECIMAL(38,6) */ - cost_usd: number; + ; }>; }; spend_summary: { @@ -92,12 +69,7 @@ declare module "@databricks/appkit-ui/react" { startDate: SQLDateMarker; }; result: Array<{ - /** @sqlType DECIMAL(33,0) */ - total: number; - /** @sqlType DECIMAL(33,0) */ - average: number; - /** @sqlType DECIMAL(33,0) */ - forecasted: number; + ; }>; }; sql_helpers_test: { @@ -117,22 +89,7 @@ declare module "@databricks/appkit-ui/react" { binaryParam: SQLStringMarker; }; result: Array<{ - /** @sqlType STRING */ - string_value: string; - /** @sqlType STRING */ - number_value: string; - /** @sqlType STRING */ - boolean_value: string; - /** @sqlType STRING */ - date_value: string; - /** @sqlType STRING */ - timestamp_value: string; - /** @sqlType BINARY */ - binary_value: string; - /** @sqlType STRING */ - binary_hex: string; - /** @sqlType INT */ - binary_length: number; + ; }>; }; top_contributors: { @@ -146,10 +103,7 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType DECIMAL(38,6) */ - total_cost_usd: number; + ; }>; }; untagged_apps: { @@ -163,14 +117,7 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType STRING */ - creator: string; - /** @sqlType DECIMAL(38,6) */ - total_cost_usd: number; - /** @sqlType DECIMAL(38,10) */ - avg_period_cost_usd: number; + ; }>; }; } diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 2b92f113..3478fcc8 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' +import { Route as FilesRouteRouteImport } from './routes/files.route' import { Route as DataVisualizationRouteRouteImport } from './routes/data-visualization.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' @@ -44,6 +45,11 @@ const LakebaseRouteRoute = LakebaseRouteRouteImport.update({ path: '/lakebase', getParentRoute: () => rootRouteImport, } as any) +const FilesRouteRoute = FilesRouteRouteImport.update({ + id: '/files', + path: '/files', + getParentRoute: () => rootRouteImport, +} as any) const DataVisualizationRouteRoute = DataVisualizationRouteRouteImport.update({ id: '/data-visualization', path: '/data-visualization', @@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute @@ -93,6 +101,7 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute @@ -106,6 +115,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/lakebase' | '/reconnect' | '/sql-helpers' @@ -117,6 +127,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/lakebase' | '/reconnect' | '/sql-helpers' @@ -128,6 +139,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/lakebase' | '/reconnect' | '/sql-helpers' @@ -140,6 +152,7 @@ export interface RootRouteChildren { AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute + FilesRouteRoute: typeof FilesRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute @@ -184,6 +197,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LakebaseRouteRouteImport parentRoute: typeof rootRouteImport } + '/files': { + id: '/files' + path: '/files' + fullPath: '/files' + preLoaderRoute: typeof FilesRouteRouteImport + parentRoute: typeof rootRouteImport + } '/data-visualization': { id: '/data-visualization' path: '/data-visualization' @@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, + FilesRouteRoute: FilesRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, SqlHelpersRouteRoute: SqlHelpersRouteRoute, diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index 842ed077..ad35594f 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -80,6 +80,14 @@ function RootComponent() { SQL Helpers + + + diff --git a/apps/dev-playground/client/src/routes/files.route.tsx b/apps/dev-playground/client/src/routes/files.route.tsx new file mode 100644 index 00000000..7c43c35a --- /dev/null +++ b/apps/dev-playground/client/src/routes/files.route.tsx @@ -0,0 +1,618 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + Button, + Card, + Skeleton, +} from "@databricks/appkit-ui/react"; +import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; +import { + AlertCircle, + ArrowLeft, + ChevronRight, + Download, + FileIcon, + FolderIcon, + FolderPlus, + Loader2, + Trash2, + Upload, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Header } from "@/components/layout/header"; + +export const Route = createFileRoute("/files")({ + component: FilesRoute, + search: { + middlewares: [retainSearchParams(true)], + }, +}); + +interface DirectoryEntry { + name?: string; + path?: string; + is_directory?: boolean; + file_size?: number; + last_modified?: string; +} + +interface FilePreview { + contentLength: number | undefined; + contentType: string | undefined; + lastModified: string | undefined; + textPreview: string | null; + isText: boolean; + isImage: boolean; +} + +function FilesRoute() { + const [volumeRoot, setVolumeRoot] = useState(""); + const [currentPath, setCurrentPath] = useState(""); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [creatingDir, setCreatingDir] = useState(false); + const [newDirName, setNewDirName] = useState(""); + const [showNewDirInput, setShowNewDirInput] = useState(false); + const fileInputRef = useRef(null); + const newDirInputRef = useRef(null); + + const normalize = (p: string) => p.replace(/\/+$/, ""); + const isAtRoot = + !currentPath || normalize(currentPath) === normalize(volumeRoot); + + const loadDirectory = useCallback(async (path?: string) => { + setLoading(true); + setError(null); + setSelectedFile(null); + setPreview(null); + + try { + const url = path + ? `/api/files/list?path=${encodeURIComponent(path)}` + : "/api/files/list"; + const response = await fetch(url); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.error ?? `HTTP ${response.status}: ${response.statusText}`, + ); + } + + const data: DirectoryEntry[] = await response.json(); + data.sort((a, b) => { + if (a.is_directory && !b.is_directory) return -1; + if (!a.is_directory && b.is_directory) return 1; + return (a.name ?? "").localeCompare(b.name ?? ""); + }); + setEntries(data); + setCurrentPath(path ?? ""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setEntries([]); + } finally { + setLoading(false); + } + }, []); + + const loadPreview = useCallback(async (filePath: string) => { + setPreviewLoading(true); + setPreview(null); + + try { + const response = await fetch( + `/api/files/preview?path=${encodeURIComponent(filePath)}`, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error ?? `HTTP ${response.status}`); + } + + const data = await response.json(); + setPreview(data); + } catch { + setPreview(null); + } finally { + setPreviewLoading(false); + } + }, []); + + useEffect(() => { + fetch("/api/files/root") + .then((res) => res.json()) + .then((data) => { + const root = data.root ?? ""; + setVolumeRoot(root); + if (root) { + loadDirectory(root); + } else { + loadDirectory(); + } + }) + .catch(() => loadDirectory()); + }, [loadDirectory]); + + const resolveEntryPath = (entry: DirectoryEntry) => { + if (entry.path?.startsWith("/")) return entry.path; + const name = entry.name ?? ""; + return currentPath ? `${currentPath}/${name}` : name; + }; + + const handleEntryClick = (entry: DirectoryEntry) => { + const entryPath = resolveEntryPath(entry); + if (entry.is_directory) { + loadDirectory(entryPath); + } else { + setSelectedFile(entryPath); + loadPreview(entryPath); + } + }; + + const navigateToParent = () => { + if (isAtRoot) return; + const segments = currentPath.split("/").filter(Boolean); + segments.pop(); + const parentPath = `/${segments.join("/")}`; + if ( + volumeRoot && + normalize(parentPath).length <= normalize(volumeRoot).length + ) { + loadDirectory(volumeRoot); + return; + } + loadDirectory(parentPath); + }; + + const navigateToBreadcrumb = (index: number) => { + const targetSegments = [ + ...rootSegments, + ...breadcrumbSegments.slice(0, index + 1), + ]; + const targetPath = `/${targetSegments.join("/")}`; + loadDirectory(targetPath); + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + try { + const uploadPath = currentPath + ? `${currentPath}/${file.name}` + : file.name; + const response = await fetch( + `/api/files/upload?path=${encodeURIComponent(uploadPath)}`, + { method: "POST", body: file }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error ?? `Upload failed (${response.status})`); + } + + await loadDirectory(currentPath || undefined); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const handleDelete = async () => { + if (!selectedFile) return; + + const fileName = selectedFile.split("/").pop(); + if (!window.confirm(`Delete "${fileName}"?`)) return; + + setDeleting(true); + try { + const response = await fetch( + `/api/files/delete?path=${encodeURIComponent(selectedFile)}`, + { method: "POST" }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error ?? `Delete failed (${response.status})`); + } + + setSelectedFile(null); + setPreview(null); + await loadDirectory(currentPath || undefined); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setDeleting(false); + } + }; + + const handleCreateDirectory = async () => { + const name = newDirName.trim(); + if (!name) return; + + setCreatingDir(true); + try { + const dirPath = currentPath ? `${currentPath}/${name}` : name; + const response = await fetch("/api/files/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: dirPath }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error( + data.error ?? `Create directory failed (${response.status})`, + ); + } + + setShowNewDirInput(false); + setNewDirName(""); + await loadDirectory(currentPath || undefined); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreatingDir(false); + } + }; + + const rootSegments = normalize(volumeRoot).split("/").filter(Boolean); + const allSegments = normalize(currentPath).split("/").filter(Boolean); + const breadcrumbSegments = allSegments.slice(rootSegments.length); + + const formatFileSize = (bytes: number | undefined) => { + if (bytes === undefined || bytes === null) return "Unknown"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+
+
+ +
+ + + + {breadcrumbSegments.length > 0 ? ( + loadDirectory(volumeRoot || undefined)} + > + {rootSegments.at(-1) ?? "Root"} + + ) : ( + + {rootSegments.at(-1) ?? "Root"} + + )} + + {breadcrumbSegments.map((segment, index) => ( + + + + {index === breadcrumbSegments.length - 1 ? ( + {segment} + ) : ( + navigateToBreadcrumb(index)} + > + {segment} + + )} + + + ))} + + + +
+ + + +
+
+ +
+ {/* File listing panel */} +
+ + {!isAtRoot && ( + + )} + + {showNewDirInput && ( +
+ + setNewDirName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateDirectory(); + if (e.key === "Escape") { + setShowNewDirInput(false); + setNewDirName(""); + } + }} + placeholder="Folder name" + className="flex-1 text-sm bg-background border rounded px-2 py-1 outline-none focus:ring-1 focus:ring-ring" + disabled={creatingDir} + /> + + +
+ )} + + {loading && ( +
+ + + +
+ )} + + {error && ( +
+ +

{error}

+ +
+ )} + + {!loading && !error && entries.length === 0 && ( +
+ +

+ {currentPath + ? "This directory is empty." + : "No default volume configured. Set DATABRICKS_DEFAULT_VOLUME to get started."} +

+
+ )} + + {!loading && + !error && + entries.map((entry) => { + const entryPath = resolveEntryPath(entry); + const isSelected = selectedFile === entryPath; + + return ( + + ); + })} +
+
+ + {/* Preview panel */} +
+ + {!selectedFile && ( +
+ +

Select a file to preview

+
+ )} + + {selectedFile && previewLoading && ( +
+ + + + +
+ )} + + {selectedFile && !previewLoading && preview && ( +
+
+

+ {selectedFile.split("/").pop()} +

+

+ {selectedFile} +

+
+ +
+
+ Size + + {formatFileSize(preview.contentLength)} + +
+
+ Type + + {preview.contentType ?? "Unknown"} + +
+ {preview.lastModified && ( +
+ Modified + + {preview.lastModified} + +
+ )} +
+ +
+ + +
+ + {preview.isImage && ( +
+ {selectedFile.split("/").pop() +
+ )} + + {preview.isText && preview.textPreview !== null && ( +
+
+                        {preview.textPreview}
+                      
+
+ )} + + {!preview.isText && !preview.isImage && ( +
+ Preview not available for this file type. +
+ )} +
+ )} + + {selectedFile && !previewLoading && !preview && ( +
+ +

+ Failed to load preview +

+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/dev-playground/client/src/routes/index.tsx b/apps/dev-playground/client/src/routes/index.tsx index 58a6f410..56b51ca5 100644 --- a/apps/dev-playground/client/src/routes/index.tsx +++ b/apps/dev-playground/client/src/routes/index.tsx @@ -145,6 +145,24 @@ function IndexRoute() { + +
+

+ File Browser +

+

+ Browse, preview, and download files from Databricks Volumes + using the Files plugin and Unity Catalog Files API. +

+ +
+
+

diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index f43da821..6ae9fa8e 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,5 +1,12 @@ import "reflect-metadata"; -import { analytics, createApp, server } from "@databricks/appkit"; +import { Readable } from "node:stream"; +import { + analytics, + contentTypeFromPath, + createApp, + files, + server, +} from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; @@ -22,6 +29,7 @@ createApp({ telemetryExamples(), analytics({}), lakebaseExamples(), + files({ defaultVolume: process.env.DATABRICKS_DEFAULT_VOLUME }), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), }).then((appkit) => { @@ -61,6 +69,224 @@ createApp({ }); }); }); + + // --- Files routes --- + + app.get("/api/files/root", (_req, res) => { + res.json({ + root: process.env.DATABRICKS_DEFAULT_VOLUME ?? null, + }); + }); + + app.get("/api/files/list", async (req, res) => { + try { + const path = req.query.path as string | undefined; + const entries = await appkit.files.asUser(req).list(path); + res.json(entries); + } catch (error) { + console.error("Files list error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "List failed", + }); + } + }); + + app.get("/api/files/read", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const content = await appkit.files.asUser(req).read(path); + res.type("text/plain").send(content); + } catch (error) { + console.error("Files read error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Read failed", + }); + } + }); + + app.get("/api/files/download", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const response = await appkit.files.asUser(req).download(path); + const fileName = path.split("/").pop() ?? "download"; + res.setHeader( + "Content-Disposition", + `attachment; filename="${fileName}"`, + ); + res.setHeader( + "Content-Type", + contentTypeFromPath(path) ?? "application/octet-stream", + ); + if (response.contents) { + const nodeStream = Readable.fromWeb( + response.contents as import("node:stream/web").ReadableStream, + ); + nodeStream.pipe(res); + } else { + res.end(); + } + } catch (error) { + console.error("Files download error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Download failed", + }); + } + }); + + app.get("/api/files/raw", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const response = await appkit.files.asUser(req).download(path); + res.setHeader( + "Content-Type", + contentTypeFromPath(path) ?? "application/octet-stream", + ); + if (response.contents) { + const nodeStream = Readable.fromWeb( + response.contents as import("node:stream/web").ReadableStream, + ); + nodeStream.pipe(res); + } else { + res.end(); + } + } catch (error) { + console.error("Files raw error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Raw fetch failed", + }); + } + }); + + app.get("/api/files/exists", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const exists = await appkit.files.asUser(req).exists(path); + res.json({ exists }); + } catch (error) { + console.error("Files exists error:", error); + res.status(500).json({ + error: + error instanceof Error ? error.message : "Exists check failed", + }); + } + }); + + app.get("/api/files/metadata", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const metadata = await appkit.files.asUser(req).metadata(path); + res.json(metadata); + } catch (error) { + console.error("Files metadata error:", error); + res.status(500).json({ + error: + error instanceof Error ? error.message : "Metadata fetch failed", + }); + } + }); + + app.post("/api/files/upload", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", async () => { + try { + const body = Buffer.concat(chunks); + await appkit.files.asUser(req).upload(path, body); + res.json({ success: true }); + } catch (error) { + console.error("Files upload error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Upload failed", + }); + } + }); + } catch (error) { + console.error("Files upload error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Upload failed", + }); + } + }); + + app.post("/api/files/mkdir", async (req, res) => { + try { + const dirPath = req.body?.path as string; + if (!dirPath) { + res.status(400).json({ error: "path is required" }); + return; + } + await appkit.files.asUser(req).createDirectory(dirPath); + res.json({ success: true }); + } catch (error) { + console.error("Files mkdir error:", error); + res.status(500).json({ + error: + error instanceof Error + ? error.message + : "Create directory failed", + }); + } + }); + + app.post("/api/files/delete", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + await appkit.files.asUser(req).delete(path); + res.json({ success: true }); + } catch (error) { + console.error("Files delete error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Delete failed", + }); + } + }); + + app.get("/api/files/preview", async (req, res) => { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required" }); + return; + } + const preview = await appkit.files.asUser(req).preview(path); + res.json(preview); + } catch (error) { + console.error("Files preview error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Preview failed", + }); + } + }); }) .start(); }); diff --git a/docs/docs/api/appkit/Function.contentTypeFromPath.md b/docs/docs/api/appkit/Function.contentTypeFromPath.md new file mode 100644 index 00000000..7b341954 --- /dev/null +++ b/docs/docs/api/appkit/Function.contentTypeFromPath.md @@ -0,0 +1,16 @@ +# Function: contentTypeFromPath() + +```ts +function contentTypeFromPath(filePath: string, reported?: string): string; +``` + +## Parameters + +| Parameter | Type | +| ------ | ------ | +| `filePath` | `string` | +| `reported?` | `string` | + +## Returns + +`string` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 91f9a66e..182936b5 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -65,6 +65,7 @@ plugin architecture, and React integration. | Function | Description | | ------ | ------ | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | +| [contentTypeFromPath](Function.contentTypeFromPath.md) | - | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 3421d7ee..63b513c2 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -200,6 +200,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.appKitTypesPlugin", label: "appKitTypesPlugin" }, + { + type: "doc", + id: "api/appkit/Function.contentTypeFromPath", + label: "contentTypeFromPath" + }, { type: "doc", id: "api/appkit/Function.createApp", diff --git a/packages/appkit/src/connectors/files/client.ts b/packages/appkit/src/connectors/files/client.ts new file mode 100644 index 00000000..41c39f0a --- /dev/null +++ b/packages/appkit/src/connectors/files/client.ts @@ -0,0 +1,345 @@ +import { ApiError, type WorkspaceClient } from "@databricks/sdk-experimental"; +import type { TelemetryOptions } from "shared"; +import { createLogger } from "../../logging/logger"; +import type { + DirectoryEntry, + DownloadResponse, + FileMetadata, + FilePreview, +} from "../../plugins/files/types"; +import type { TelemetryProvider } from "../../telemetry"; +import { + type Counter, + type Histogram, + type Span, + SpanKind, + SpanStatusCode, + TelemetryManager, +} from "../../telemetry"; +import { contentTypeFromPath } from "./defaults"; + +const logger = createLogger("connectors:files"); + +export interface FilesConnectorConfig { + defaultVolume?: string; + timeout?: number; + telemetry?: TelemetryOptions; + customContentTypes?: Record; +} + +export class FilesConnector { + private readonly name = "files"; + private defaultVolume: string | undefined; + private readonly customContentTypes: Record | undefined; + + private readonly telemetry: TelemetryProvider; + private readonly telemetryMetrics: { + operationCount: Counter; + operationDuration: Histogram; + }; + + constructor(config: FilesConnectorConfig) { + this.defaultVolume = config.defaultVolume; + this.customContentTypes = config.customContentTypes; + + this.telemetry = TelemetryManager.getProvider(this.name, config.telemetry); + this.telemetryMetrics = { + operationCount: this.telemetry + .getMeter() + .createCounter("files.operation.count", { + description: "Total number of file operations", + unit: "1", + }), + operationDuration: this.telemetry + .getMeter() + .createHistogram("files.operation.duration", { + description: "Duration of file operations", + unit: "ms", + }), + }; + } + + private resolvePath(filePath: string): string { + if (filePath.startsWith("/")) { + return filePath; + } + if (!this.defaultVolume) { + throw new Error( + "Cannot resolve relative path: no default volume set. Use an absolute path or set a default volume.", + ); + } + return `${this.defaultVolume}/${filePath}`; + } + + volume(volumePath: string): FilesConnector { + return new FilesConnector({ + defaultVolume: volumePath, + telemetry: false, + customContentTypes: this.customContentTypes, + }); + } + + private async traced( + operation: string, + attributes: Record, + fn: (span: Span) => Promise, + ): Promise { + const startTime = Date.now(); + let success = false; + + return this.telemetry.startActiveSpan( + `files.${operation}`, + { + kind: SpanKind.CLIENT, + attributes: { + "files.operation": operation, + ...attributes, + }, + }, + async (span: Span) => { + try { + const result = await fn(span); + success = true; + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + span.end(); + const duration = Date.now() - startTime; + const metricAttrs = { + "files.operation": operation, + success: String(success), + }; + this.telemetryMetrics.operationCount.add(1, metricAttrs); + this.telemetryMetrics.operationDuration.record(duration, metricAttrs); + } + }, + { name: this.name, includePrefix: true }, + ); + } + + async list( + client: WorkspaceClient, + directoryPath?: string, + ): Promise { + const resolvedPath = directoryPath + ? this.resolvePath(directoryPath) + : this.defaultVolume; + if (!resolvedPath) { + throw new Error("No directory path provided and no default volume set."); + } + + return this.traced("list", { "files.path": resolvedPath }, async () => { + const entries: DirectoryEntry[] = []; + for await (const entry of client.files.listDirectoryContents({ + directory_path: resolvedPath, + })) { + entries.push(entry); + } + return entries; + }); + } + + async read(client: WorkspaceClient, filePath: string): Promise { + return this.traced( + "read", + { "files.path": this.resolvePath(filePath) }, + async () => { + const response = await this.download(client, filePath); + if (!response.contents) { + return ""; + } + const reader = response.contents.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; + }, + ); + } + + async download( + client: WorkspaceClient, + filePath: string, + ): Promise { + return this.traced( + "download", + { "files.path": this.resolvePath(filePath) }, + async () => { + return client.files.download({ + file_path: this.resolvePath(filePath), + }); + }, + ); + } + + async exists(client: WorkspaceClient, filePath: string): Promise { + return this.traced( + "exists", + { "files.path": this.resolvePath(filePath) }, + async () => { + try { + await this.metadata(client, filePath); + return true; + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + return false; + } + throw error; + } + }, + ); + } + + async metadata( + client: WorkspaceClient, + filePath: string, + ): Promise { + return this.traced( + "metadata", + { "files.path": this.resolvePath(filePath) }, + async () => { + const response = await client.files.getMetadata({ + file_path: this.resolvePath(filePath), + }); + return { + contentLength: response["content-length"], + contentType: contentTypeFromPath( + filePath, + response["content-type"], + this.customContentTypes, + ), + lastModified: response["last-modified"], + }; + }, + ); + } + + async upload( + client: WorkspaceClient, + filePath: string, + contents: ReadableStream | Buffer | string, + options?: { overwrite?: boolean }, + ): Promise { + const resolvedPath = this.resolvePath(filePath); + + return this.traced("upload", { "files.path": resolvedPath }, async () => { + const body = contents; + const overwrite = options?.overwrite ?? true; + + // Workaround: The SDK's files.upload() has two bugs: + // 1. It ignores the `contents` field (sets body to undefined) + // 2. apiClient.request() checks `instanceof` against its own ReadableStream + // subclass, so standard ReadableStream instances get JSON.stringified to "{}" + // Bypass both by calling the REST API directly with SDK-provided auth. + const url = new URL( + `/api/2.0/fs/files${resolvedPath}`, + client.config.host, + ); + url.searchParams.set("overwrite", String(overwrite)); + + const headers = new Headers({ + "Content-Type": "application/octet-stream", + }); + const fetchOptions: RequestInit = { method: "PUT", headers, body }; + + if (body instanceof ReadableStream) { + fetchOptions.duplex = "half"; + } + + await client.config.authenticate(headers); + + const res = await fetch(url.toString(), fetchOptions); + + if (!res.ok) { + const text = await res.text(); + logger.error(`Upload failed (${res.status}): ${text}`); + throw new Error(`Upload failed (${res.status}): ${text}`); + } + }); + } + + async createDirectory( + client: WorkspaceClient, + directoryPath: string, + ): Promise { + return this.traced( + "createDirectory", + { "files.path": this.resolvePath(directoryPath) }, + async () => { + await client.files.createDirectory({ + directory_path: this.resolvePath(directoryPath), + }); + }, + ); + } + + async delete(client: WorkspaceClient, filePath: string): Promise { + return this.traced( + "delete", + { "files.path": this.resolvePath(filePath) }, + async () => { + await client.files.delete({ + file_path: this.resolvePath(filePath), + }); + }, + ); + } + + async preview( + client: WorkspaceClient, + filePath: string, + options?: { maxBytes?: number }, + ): Promise { + return this.traced( + "preview", + { "files.path": this.resolvePath(filePath) }, + async () => { + const meta = await this.metadata(client, filePath); + const isText = isTextContentType(meta.contentType); + const isImage = meta.contentType?.startsWith("image/") || false; + + if (!isText) { + return { ...meta, textPreview: null, isText: false, isImage }; + } + + const response = await client.files.download({ + file_path: this.resolvePath(filePath), + }); + if (!response.contents) { + return { ...meta, textPreview: "", isText: true, isImage: false }; + } + + const reader = response.contents.getReader(); + const decoder = new TextDecoder(); + let preview = ""; + const maxBytes = options?.maxBytes ?? 1024; + + while (preview.length < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + preview += decoder.decode(value, { stream: true }); + } + preview += decoder.decode(); + await reader.cancel(); + + if (preview.length > maxBytes) { + preview = preview.slice(0, maxBytes); + } + + return { ...meta, textPreview: preview, isText: true, isImage: false }; + }, + ); + } +} diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index ec729d33..c8f0d7e5 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -47,6 +47,7 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, files, server } from "./plugins"; +export { contentTypeFromPath } from "./plugins/files"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/files/files.ts b/packages/appkit/src/plugins/files/files.ts index 65412dbe..0f256624 100644 --- a/packages/appkit/src/plugins/files/files.ts +++ b/packages/appkit/src/plugins/files/files.ts @@ -1,5 +1,7 @@ import type { IAppRouter } from "shared"; +import { getWorkspaceClient } from "../../context"; import { Plugin, toPlugin } from "../../plugin"; +import { FilesClient } from "./lib"; import { filesManifest } from "./manifest"; import type { IFilesConfig } from "./types"; @@ -15,20 +17,50 @@ export class FilesPlugin extends Plugin { constructor(config: IFilesConfig) { super(config); this.config = config; - // TODO: Initialize file operation services + } + + /** + * Create a FilesClient scoped to the current execution context. + * Must be called per-request so `asUser()` context is respected. + */ + private getFilesClient(): FilesClient { + const client = getWorkspaceClient(); + return new FilesClient({ + defaultVolume: this.config.defaultVolume, + client, + }); } injectRoutes(_router: IAppRouter) { - // TODO: Register file operation routes + // Routes are handled in the app layer via server.extend() } async shutdown(): Promise { this.streamManager.abortAll(); } + /** + * Returns the public exports for the files plugin. + * Note: `asUser()` is automatically added by AppKit. + */ exports() { - // TODO: Return public API methods - return {}; + return { + list: (directoryPath?: string) => + this.getFilesClient().list(directoryPath), + read: (filePath: string) => this.getFilesClient().read(filePath), + download: (filePath: string) => this.getFilesClient().download(filePath), + exists: (filePath: string) => this.getFilesClient().exists(filePath), + metadata: (filePath: string) => this.getFilesClient().metadata(filePath), + upload: ( + filePath: string, + contents: ReadableStream | Buffer | string, + options?: { overwrite?: boolean }, + ) => this.getFilesClient().upload(filePath, contents, options), + createDirectory: (directoryPath: string) => + this.getFilesClient().createDirectory(directoryPath), + delete: (filePath: string) => this.getFilesClient().delete(filePath), + preview: (filePath: string) => this.getFilesClient().preview(filePath), + }; } } diff --git a/packages/appkit/src/plugins/files/index.ts b/packages/appkit/src/plugins/files/index.ts index eaaa19b1..ac62c6d9 100644 --- a/packages/appkit/src/plugins/files/index.ts +++ b/packages/appkit/src/plugins/files/index.ts @@ -1,3 +1,4 @@ export * from "./files"; +export * from "./helpers"; export * from "./manifest"; export * from "./types"; diff --git a/packages/appkit/src/plugins/files/lib.ts b/packages/appkit/src/plugins/files/lib.ts index f221f47f..0ddff1c8 100644 --- a/packages/appkit/src/plugins/files/lib.ts +++ b/packages/appkit/src/plugins/files/lib.ts @@ -152,6 +152,12 @@ export class FilesClient { } } + async createDirectory(directoryPath: string): Promise { + await this.client.files.createDirectory({ + directory_path: this.resolvePath(directoryPath), + }); + } + async delete(filePath: string): Promise { await this.client.files.delete({ file_path: this.resolvePath(filePath), diff --git a/packages/appkit/src/plugins/files/types.ts b/packages/appkit/src/plugins/files/types.ts index 41755497..11982a32 100644 --- a/packages/appkit/src/plugins/files/types.ts +++ b/packages/appkit/src/plugins/files/types.ts @@ -1,9 +1,10 @@ -// import type { BasePluginConfig } from "shared"; import type { files } from "@databricks/sdk-experimental"; +import type { BasePluginConfig } from "shared"; -// export interface IFilesConfig extends BasePluginConfig { -// timeout?: number; -// } +export interface IFilesConfig extends BasePluginConfig { + timeout?: number; + defaultVolume?: string; +} // TODO: Add request/response types for file operations export type DirectoryEntry = files.DirectoryEntry; From be66fdd1bfd13c2b207d0da3e143c564afa46daa Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 10:51:12 +0100 Subject: [PATCH 03/23] chore: add `injectRoutes` logic to plugin --- .../client/src/routes/files.route.tsx | 13 +- .../client/src/routes/index.tsx | 22 +- apps/dev-playground/server/index.ts | 227 +---- packages/appkit/src/plugins/files/README.md | 44 + packages/appkit/src/plugins/files/files.ts | 73 -- packages/appkit/src/plugins/files/index.ts | 2 +- packages/appkit/src/plugins/files/plugin.ts | 443 +++++++++ .../src/plugins/files/tests/lib.test.ts | 857 ++++++++++++++++++ .../files/tests/plugin.integration.test.ts | 354 ++++++++ .../src/plugins/files/tests/plugin.test.ts | 625 +++++++++++++ 10 files changed, 2342 insertions(+), 318 deletions(-) create mode 100644 packages/appkit/src/plugins/files/README.md delete mode 100644 packages/appkit/src/plugins/files/files.ts create mode 100644 packages/appkit/src/plugins/files/plugin.ts create mode 100644 packages/appkit/src/plugins/files/tests/lib.test.ts create mode 100644 packages/appkit/src/plugins/files/tests/plugin.integration.test.ts create mode 100644 packages/appkit/src/plugins/files/tests/plugin.test.ts diff --git a/apps/dev-playground/client/src/routes/files.route.tsx b/apps/dev-playground/client/src/routes/files.route.tsx index 7c43c35a..67317eb4 100644 --- a/apps/dev-playground/client/src/routes/files.route.tsx +++ b/apps/dev-playground/client/src/routes/files.route.tsx @@ -364,8 +364,7 @@ function FilesRoute() {

- {/* File listing panel */} -
+
{!isAtRoot && (
@@ -148,17 +147,18 @@ function IndexRoute() {

- File Browser + SQL Helpers

- Browse, preview, and download files from Databricks Volumes - using the Files plugin and Unity Catalog Files API. + Type-safe parameter helpers for Databricks SQL queries. Test + each helper interactively and see the generated parameter + objects.

diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 6ae9fa8e..3e514140 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,12 +1,5 @@ import "reflect-metadata"; -import { Readable } from "node:stream"; -import { - analytics, - contentTypeFromPath, - createApp, - files, - server, -} from "@databricks/appkit"; +import { analytics, createApp, files, server } from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; @@ -69,224 +62,6 @@ createApp({ }); }); }); - - // --- Files routes --- - - app.get("/api/files/root", (_req, res) => { - res.json({ - root: process.env.DATABRICKS_DEFAULT_VOLUME ?? null, - }); - }); - - app.get("/api/files/list", async (req, res) => { - try { - const path = req.query.path as string | undefined; - const entries = await appkit.files.asUser(req).list(path); - res.json(entries); - } catch (error) { - console.error("Files list error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "List failed", - }); - } - }); - - app.get("/api/files/read", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const content = await appkit.files.asUser(req).read(path); - res.type("text/plain").send(content); - } catch (error) { - console.error("Files read error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Read failed", - }); - } - }); - - app.get("/api/files/download", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const response = await appkit.files.asUser(req).download(path); - const fileName = path.split("/").pop() ?? "download"; - res.setHeader( - "Content-Disposition", - `attachment; filename="${fileName}"`, - ); - res.setHeader( - "Content-Type", - contentTypeFromPath(path) ?? "application/octet-stream", - ); - if (response.contents) { - const nodeStream = Readable.fromWeb( - response.contents as import("node:stream/web").ReadableStream, - ); - nodeStream.pipe(res); - } else { - res.end(); - } - } catch (error) { - console.error("Files download error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Download failed", - }); - } - }); - - app.get("/api/files/raw", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const response = await appkit.files.asUser(req).download(path); - res.setHeader( - "Content-Type", - contentTypeFromPath(path) ?? "application/octet-stream", - ); - if (response.contents) { - const nodeStream = Readable.fromWeb( - response.contents as import("node:stream/web").ReadableStream, - ); - nodeStream.pipe(res); - } else { - res.end(); - } - } catch (error) { - console.error("Files raw error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Raw fetch failed", - }); - } - }); - - app.get("/api/files/exists", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const exists = await appkit.files.asUser(req).exists(path); - res.json({ exists }); - } catch (error) { - console.error("Files exists error:", error); - res.status(500).json({ - error: - error instanceof Error ? error.message : "Exists check failed", - }); - } - }); - - app.get("/api/files/metadata", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const metadata = await appkit.files.asUser(req).metadata(path); - res.json(metadata); - } catch (error) { - console.error("Files metadata error:", error); - res.status(500).json({ - error: - error instanceof Error ? error.message : "Metadata fetch failed", - }); - } - }); - - app.post("/api/files/upload", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", async () => { - try { - const body = Buffer.concat(chunks); - await appkit.files.asUser(req).upload(path, body); - res.json({ success: true }); - } catch (error) { - console.error("Files upload error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Upload failed", - }); - } - }); - } catch (error) { - console.error("Files upload error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Upload failed", - }); - } - }); - - app.post("/api/files/mkdir", async (req, res) => { - try { - const dirPath = req.body?.path as string; - if (!dirPath) { - res.status(400).json({ error: "path is required" }); - return; - } - await appkit.files.asUser(req).createDirectory(dirPath); - res.json({ success: true }); - } catch (error) { - console.error("Files mkdir error:", error); - res.status(500).json({ - error: - error instanceof Error - ? error.message - : "Create directory failed", - }); - } - }); - - app.post("/api/files/delete", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - await appkit.files.asUser(req).delete(path); - res.json({ success: true }); - } catch (error) { - console.error("Files delete error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Delete failed", - }); - } - }); - - app.get("/api/files/preview", async (req, res) => { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required" }); - return; - } - const preview = await appkit.files.asUser(req).preview(path); - res.json(preview); - } catch (error) { - console.error("Files preview error:", error); - res.status(500).json({ - error: error instanceof Error ? error.message : "Preview failed", - }); - } - }); }) .start(); }); diff --git a/packages/appkit/src/plugins/files/README.md b/packages/appkit/src/plugins/files/README.md new file mode 100644 index 00000000..7a9922f0 --- /dev/null +++ b/packages/appkit/src/plugins/files/README.md @@ -0,0 +1,44 @@ +# Files Plugin + +The files plugin provides HTTP routes for Databricks Unity Catalog volume file operations. +Routes are automatically registered via `injectRoutes` and mounted at `/api/files/*`. + +## Routes + +All routes (except `/root`) execute in user context via `asUser(req)`. + +| Method | Path | Query/Body Params | Response | `exports()` method | +| ------ | ----------- | ---------------------------- | ------------------------------------------------- | ------------------- | +| GET | `/root` | - | `{ root: string \| null }` | - | +| GET | `/list` | `?path` (optional) | `DirectoryEntry[]` | `list()` | +| GET | `/read` | `?path` (required) | `text/plain` body | `read()` | +| GET | `/download` | `?path` (required) | Binary stream (`Content-Disposition: attachment`) | `download()` | +| GET | `/raw` | `?path` (required) | Binary stream (inline) | `download()` | +| GET | `/exists` | `?path` (required) | `{ exists: boolean }` | `exists()` | +| GET | `/metadata` | `?path` (required) | `FileMetadata` | `metadata()` | +| GET | `/preview` | `?path` (required) | `FilePreview` | `preview()` | +| POST | `/upload` | `?path` (required), raw body | `{ success: true }` | `upload()` | +| POST | `/mkdir` | `body.path` (required) | `{ success: true }` | `createDirectory()` | +| POST | `/delete` | `?path` (required) | `{ success: true }` | `delete()` | + +## Error responses + +All errors return JSON with the shape: + +```json +{ + "error": "Human-readable message", + "plugin": "files" +} +``` + +## HTTP Status Codes + +| Status | Description | +| ------ | --------------------------------------- | +| 400 | Missing required `path` parameter | +| 500 | Operation failed (SDK or network error) | + +## User context + +Routes use `this.asUser(req)` which wraps the plugin's `getFilesClient()` so that the underlying `getWorkspaceClient()` returns a client scoped to the requesting user's Databricks credentials (on-behalf-of / OBO). The `/root` route is the only exception since it only reads plugin config. diff --git a/packages/appkit/src/plugins/files/files.ts b/packages/appkit/src/plugins/files/files.ts deleted file mode 100644 index 0f256624..00000000 --- a/packages/appkit/src/plugins/files/files.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { IAppRouter } from "shared"; -import { getWorkspaceClient } from "../../context"; -import { Plugin, toPlugin } from "../../plugin"; -import { FilesClient } from "./lib"; -import { filesManifest } from "./manifest"; -import type { IFilesConfig } from "./types"; - -export class FilesPlugin extends Plugin { - name = "files"; - - /** Plugin manifest declaring metadata and resource requirements */ - static manifest = filesManifest; - - protected static description = "Files plugin for Databricks file operations"; - protected declare config: IFilesConfig; - - constructor(config: IFilesConfig) { - super(config); - this.config = config; - } - - /** - * Create a FilesClient scoped to the current execution context. - * Must be called per-request so `asUser()` context is respected. - */ - private getFilesClient(): FilesClient { - const client = getWorkspaceClient(); - return new FilesClient({ - defaultVolume: this.config.defaultVolume, - client, - }); - } - - injectRoutes(_router: IAppRouter) { - // Routes are handled in the app layer via server.extend() - } - - async shutdown(): Promise { - this.streamManager.abortAll(); - } - - /** - * Returns the public exports for the files plugin. - * Note: `asUser()` is automatically added by AppKit. - */ - exports() { - return { - list: (directoryPath?: string) => - this.getFilesClient().list(directoryPath), - read: (filePath: string) => this.getFilesClient().read(filePath), - download: (filePath: string) => this.getFilesClient().download(filePath), - exists: (filePath: string) => this.getFilesClient().exists(filePath), - metadata: (filePath: string) => this.getFilesClient().metadata(filePath), - upload: ( - filePath: string, - contents: ReadableStream | Buffer | string, - options?: { overwrite?: boolean }, - ) => this.getFilesClient().upload(filePath, contents, options), - createDirectory: (directoryPath: string) => - this.getFilesClient().createDirectory(directoryPath), - delete: (filePath: string) => this.getFilesClient().delete(filePath), - preview: (filePath: string) => this.getFilesClient().preview(filePath), - }; - } -} - -/** - * @internal - */ -export const files = toPlugin( - FilesPlugin, - "files", -); diff --git a/packages/appkit/src/plugins/files/index.ts b/packages/appkit/src/plugins/files/index.ts index ac62c6d9..8bb872c1 100644 --- a/packages/appkit/src/plugins/files/index.ts +++ b/packages/appkit/src/plugins/files/index.ts @@ -1,4 +1,4 @@ -export * from "./files"; export * from "./helpers"; export * from "./manifest"; +export * from "./plugin"; export * from "./types"; diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts new file mode 100644 index 00000000..8d6a3b7e --- /dev/null +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -0,0 +1,443 @@ +import { Readable } from "node:stream"; +import type express from "express"; +import type { IAppRouter } from "shared"; +import { getWorkspaceClient } from "../../context"; +import { Plugin, toPlugin } from "../../plugin"; +import { contentTypeFromPath } from "./helpers"; +import { FilesClient } from "./lib"; +import { filesManifest } from "./manifest"; +import type { DownloadResponse, IFilesConfig } from "./types"; + +export class FilesPlugin extends Plugin { + name = "files"; + + static manifest = filesManifest; + protected static description = "Files plugin for Databricks file operations"; + protected declare config: IFilesConfig; + + constructor(config: IFilesConfig) { + super(config); + this.config = config; + } + + /** + * Create a FilesClient scoped to the current execution context. + * Must be called per-request so `asUser()` context is respected. + */ + private getFilesClient(): FilesClient { + const client = getWorkspaceClient(); + return new FilesClient({ + defaultVolume: this.config.defaultVolume, + client, + }); + } + + // --- Public methods (proxied by asUser) --- + + async list(directoryPath?: string) { + return this.getFilesClient().list(directoryPath); + } + + async read(filePath: string) { + return this.getFilesClient().read(filePath); + } + + async download(filePath: string): Promise { + return this.getFilesClient().download(filePath); + } + + async exists(filePath: string) { + return this.getFilesClient().exists(filePath); + } + + async metadata(filePath: string) { + return this.getFilesClient().metadata(filePath); + } + + async upload( + filePath: string, + contents: ReadableStream | Buffer | string, + options?: { overwrite?: boolean }, + ) { + return this.getFilesClient().upload(filePath, contents, options); + } + + async createDirectory(directoryPath: string) { + return this.getFilesClient().createDirectory(directoryPath); + } + + async delete(filePath: string) { + return this.getFilesClient().delete(filePath); + } + + async preview(filePath: string) { + return this.getFilesClient().preview(filePath); + } + + // --- Routes --- + + injectRoutes(router: IAppRouter) { + this.route(router, { + name: "root", + method: "get", + path: "/root", + handler: async (_req: express.Request, res: express.Response) => { + res.json({ root: this.config.defaultVolume ?? null }); + }, + }); + + this.route(router, { + name: "list", + method: "get", + path: "/list", + handler: async (req: express.Request, res: express.Response) => { + await this._handleList(req, res); + }, + }); + + this.route(router, { + name: "read", + method: "get", + path: "/read", + handler: async (req: express.Request, res: express.Response) => { + await this._handleRead(req, res); + }, + }); + + this.route(router, { + name: "download", + method: "get", + path: "/download", + handler: async (req: express.Request, res: express.Response) => { + await this._handleDownload(req, res); + }, + }); + + this.route(router, { + name: "raw", + method: "get", + path: "/raw", + handler: async (req: express.Request, res: express.Response) => { + await this._handleRaw(req, res); + }, + }); + + this.route(router, { + name: "exists", + method: "get", + path: "/exists", + handler: async (req: express.Request, res: express.Response) => { + await this._handleExists(req, res); + }, + }); + + this.route(router, { + name: "metadata", + method: "get", + path: "/metadata", + handler: async (req: express.Request, res: express.Response) => { + await this._handleMetadata(req, res); + }, + }); + + this.route(router, { + name: "preview", + method: "get", + path: "/preview", + handler: async (req: express.Request, res: express.Response) => { + await this._handlePreview(req, res); + }, + }); + + this.route(router, { + name: "upload", + method: "post", + path: "/upload", + handler: async (req: express.Request, res: express.Response) => { + await this._handleUpload(req, res); + }, + }); + + this.route(router, { + name: "mkdir", + method: "post", + path: "/mkdir", + handler: async (req: express.Request, res: express.Response) => { + await this._handleMkdir(req, res); + }, + }); + + this.route(router, { + name: "delete", + method: "post", + path: "/delete", + handler: async (req: express.Request, res: express.Response) => { + await this._handleDelete(req, res); + }, + }); + } + + // --- Private route handlers --- + + private async _handleList( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string | undefined; + const entries = await this.asUser(req).list(path); + res.json(entries); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "List failed", + plugin: this.name, + }); + } + } + + private async _handleRead( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const content = await this.asUser(req).read(path); + res.type("text/plain").send(content); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Read failed", + plugin: this.name, + }); + } + } + + private async _handleDownload( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const response = await this.asUser(req).download(path); + const fileName = path.split("/").pop() ?? "download"; + res.setHeader( + "Content-Disposition", + `attachment; filename="${fileName}"`, + ); + res.setHeader( + "Content-Type", + contentTypeFromPath(path) ?? "application/octet-stream", + ); + if (response.contents) { + const nodeStream = Readable.fromWeb( + response.contents as import("node:stream/web").ReadableStream, + ); + nodeStream.pipe(res); + } else { + res.end(); + } + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Download failed", + plugin: this.name, + }); + } + } + + private async _handleRaw( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const response = await this.asUser(req).download(path); + res.setHeader( + "Content-Type", + contentTypeFromPath(path) ?? "application/octet-stream", + ); + if (response.contents) { + const nodeStream = Readable.fromWeb( + response.contents as import("node:stream/web").ReadableStream, + ); + nodeStream.pipe(res); + } else { + res.end(); + } + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Raw fetch failed", + plugin: this.name, + }); + } + } + + private async _handleExists( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const exists = await this.asUser(req).exists(path); + res.json({ exists }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Exists check failed", + plugin: this.name, + }); + } + } + + private async _handleMetadata( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const metadata = await this.asUser(req).metadata(path); + res.json(metadata); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Metadata fetch failed", + plugin: this.name, + }); + } + } + + private async _handlePreview( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const preview = await this.asUser(req).preview(path); + res.json(preview); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Preview failed", + plugin: this.name, + }); + } + } + + private async _handleUpload( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", async () => { + try { + const body = Buffer.concat(chunks); + await this.asUser(req).upload(path, body); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Upload failed", + plugin: this.name, + }); + } + }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Upload failed", + plugin: this.name, + }); + } + } + + private async _handleMkdir( + req: express.Request, + res: express.Response, + ): Promise { + try { + const dirPath = req.body?.path as string; + if (!dirPath) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + await this.asUser(req).createDirectory(dirPath); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ + error: + error instanceof Error ? error.message : "Create directory failed", + plugin: this.name, + }); + } + } + + private async _handleDelete( + req: express.Request, + res: express.Response, + ): Promise { + try { + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + await this.asUser(req).delete(path); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Delete failed", + plugin: this.name, + }); + } + } + + async shutdown(): Promise { + this.streamManager.abortAll(); + } + + exports() { + return { + list: this.list, + read: this.read, + download: this.download, + exists: this.exists, + metadata: this.metadata, + upload: this.upload, + createDirectory: this.createDirectory, + delete: this.delete, + preview: this.preview, + }; + } +} + +/** + * @internal + */ +export const files = toPlugin( + FilesPlugin, + "files", +); diff --git a/packages/appkit/src/plugins/files/tests/lib.test.ts b/packages/appkit/src/plugins/files/tests/lib.test.ts new file mode 100644 index 00000000..0f04eaec --- /dev/null +++ b/packages/appkit/src/plugins/files/tests/lib.test.ts @@ -0,0 +1,857 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { contentTypeFromPath } from "../helpers"; +import { FilesClient } from "../lib"; + +// --------------------------------------------------------------------------- +// Mock SDK +// --------------------------------------------------------------------------- +const { mockFilesApi, mockClient, MockApiError } = vi.hoisted(() => { + const mockFilesApi = { + listDirectoryContents: vi.fn(), + download: vi.fn(), + getMetadata: vi.fn(), + upload: vi.fn(), + createDirectory: vi.fn(), + delete: vi.fn(), + }; + + const mockClient = { + files: mockFilesApi, + config: { + host: "https://test.databricks.com", + authenticate: vi.fn(), + }, + }; + + class MockApiError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + } + } + + return { mockFilesApi, mockClient, MockApiError }; +}); + +vi.mock("@databricks/sdk-experimental", () => ({ + WorkspaceClient: vi.fn(() => mockClient), + ApiError: MockApiError, +})); + +// --------------------------------------------------------------------------- +// Helper: create a ReadableStream from a string +// --------------------------------------------------------------------------- +function streamFromString(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +// Creates a ReadableStream that yields multiple chunks +function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); +} + +// ========================================================================= +// contentTypeFromPath +// ========================================================================= +describe("contentTypeFromPath", () => { + test("returns reported content-type when not application/octet-stream", () => { + expect(contentTypeFromPath("/file.txt", "text/html")).toBe("text/html"); + }); + + test("falls back to extension lookup when reported is application/octet-stream", () => { + expect(contentTypeFromPath("/image.png", "application/octet-stream")).toBe( + "image/png", + ); + }); + + test("falls back to extension lookup when no reported type", () => { + expect(contentTypeFromPath("/data.json")).toBe("application/json"); + }); + + test("returns application/octet-stream for unknown extensions with no reported type", () => { + expect(contentTypeFromPath("/file.xyz")).toBe("application/octet-stream"); + }); + + test("handles case-insensitive extensions", () => { + expect(contentTypeFromPath("/image.PNG")).toBe("image/png"); + expect(contentTypeFromPath("/data.Json")).toBe("application/json"); + }); + + test("uses extension when reported is undefined", () => { + expect(contentTypeFromPath("/style.css", undefined)).toBe("text/css"); + }); + + test("returns reported type for known extensions when reported differs", () => { + // If the server says it's text/html, trust it even for a .json file + expect(contentTypeFromPath("/file.json", "text/html")).toBe("text/html"); + }); + + test("handles paths with multiple dots", () => { + expect(contentTypeFromPath("/archive.tar.gz")).toBe( + "application/octet-stream", + ); + expect(contentTypeFromPath("/data.backup.json")).toBe("application/json"); + }); +}); + +// ========================================================================= +// FilesClient – Path Resolution +// ========================================================================= +describe("FilesClient – Path Resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("absolute paths are returned as-is", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("/Volumes/other/path/file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/other/path/file.txt", + }); + }); + + test("relative paths prepend defaultVolume", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("subdir/file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/subdir/file.txt", + }); + }); + + test("relative path without defaultVolume throws error", async () => { + const client = new FilesClient({ client: mockClient as any }); + + await expect(client.download("file.txt")).rejects.toThrow( + "Cannot resolve relative path: no default volume set.", + ); + }); + + test("volume() creates new client scoped to a different volume", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol1", + client: mockClient as any, + }); + + const scoped = client.volume("/Volumes/catalog/schema/vol2"); + + mockFilesApi.download.mockResolvedValue({ contents: null }); + scoped.download("file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol2/file.txt", + }); + }); + + test("volume() does not affect the original client", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol1", + client: mockClient as any, + }); + + client.volume("/Volumes/catalog/schema/vol2"); + + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol1/file.txt", + }); + }); + + test("constructor without defaultVolume omits it", async () => { + const client = new FilesClient({ client: mockClient as any }); + + await expect(client.list()).rejects.toThrow( + "No directory path provided and no default volume set.", + ); + }); +}); + +// ========================================================================= +// FilesClient – list() +// ========================================================================= +describe("FilesClient – list()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("collects async iterator entries", async () => { + const entries = [ + { + name: "file1.txt", + path: "/Volumes/catalog/schema/vol/file1.txt", + is_directory: false, + }, + { + name: "subdir", + path: "/Volumes/catalog/schema/vol/subdir", + is_directory: true, + }, + ]; + + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () { + for (const entry of entries) { + yield entry; + } + })(), + ); + + const result = await client.list(); + + expect(result).toEqual(entries); + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol", + }); + }); + + test("uses defaultVolume when no path provided", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + await client.list(); + + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol", + }); + }); + + test("throws when no path and no defaultVolume", async () => { + const noVolumeClient = new FilesClient({ client: mockClient as any }); + + await expect(noVolumeClient.list()).rejects.toThrow( + "No directory path provided and no default volume set.", + ); + }); + + test("uses provided absolute path", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + await client.list("/Volumes/other/path"); + + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/other/path", + }); + }); + + test("resolves relative path with defaultVolume", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + await client.list("subdir"); + + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol/subdir", + }); + }); + + test("returns empty array for empty directory", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + const result = await client.list(); + + expect(result).toEqual([]); + }); +}); + +// ========================================================================= +// FilesClient – read() +// ========================================================================= +describe("FilesClient – read()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("decodes ReadableStream to UTF-8 string", async () => { + const content = "Hello, world!"; + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); + + const result = await client.read("/file.txt"); + + expect(result).toBe(content); + }); + + test("returns empty string when contents is null", async () => { + mockFilesApi.download.mockResolvedValue({ contents: null }); + + const result = await client.read("/empty.txt"); + + expect(result).toBe(""); + }); + + test("concatenates multiple chunks correctly", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromChunks(["Hello, ", "world", "!"]), + }); + + const result = await client.read("/chunked.txt"); + + expect(result).toBe("Hello, world!"); + }); + + test("handles multi-byte UTF-8 characters", async () => { + const content = "Héllo wörld 🌍"; + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); + + const result = await client.read("/unicode.txt"); + + expect(result).toBe(content); + }); +}); + +// ========================================================================= +// FilesClient – download() +// ========================================================================= +describe("FilesClient – download()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("calls client.files.download with resolved path", async () => { + const response = { contents: streamFromString("data") }; + mockFilesApi.download.mockResolvedValue(response); + + const result = await client.download("file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/file.txt", + }); + expect(result).toBe(response); + }); + + test("passes absolute path directly", async () => { + const response = { contents: null }; + mockFilesApi.download.mockResolvedValue(response); + + await client.download("/Volumes/other/file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/other/file.txt", + }); + }); +}); + +// ========================================================================= +// FilesClient – exists() +// ========================================================================= +describe("FilesClient – exists()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("returns true when metadata succeeds", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 100, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + + const result = await client.exists("/file.txt"); + + expect(result).toBe(true); + }); + + test("returns false on 404 ApiError", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Not found", 404), + ); + + const result = await client.exists("/missing.txt"); + + expect(result).toBe(false); + }); + + test("rethrows non-404 ApiError", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Server error", 500), + ); + + await expect(client.exists("/file.txt")).rejects.toThrow("Server error"); + }); + + test("rethrows generic errors", async () => { + mockFilesApi.getMetadata.mockRejectedValue(new Error("Network failure")); + + await expect(client.exists("/file.txt")).rejects.toThrow("Network failure"); + }); +}); + +// ========================================================================= +// FilesClient – metadata() +// ========================================================================= +describe("FilesClient – metadata()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("maps SDK response to FileMetadata", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 1234, + "content-type": "application/json", + "last-modified": "2025-06-15T10:00:00Z", + }); + + const result = await client.metadata("/data.json"); + + expect(result).toEqual({ + contentLength: 1234, + contentType: "application/json", + lastModified: "2025-06-15T10:00:00Z", + }); + }); + + test("uses contentTypeFromPath to resolve octet-stream", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 500, + "content-type": "application/octet-stream", + "last-modified": "2025-01-01", + }); + + const result = await client.metadata("/image.png"); + + expect(result.contentType).toBe("image/png"); + }); + + test("handles undefined content-type from SDK", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 100, + "content-type": undefined, + "last-modified": "2025-01-01", + }); + + const result = await client.metadata("/data.csv"); + + expect(result.contentType).toBe("text/csv"); + }); + + test("resolves relative path via defaultVolume", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 0, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + + await client.metadata("notes.txt"); + + expect(mockFilesApi.getMetadata).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/notes.txt", + }); + }); +}); + +// ========================================================================= +// FilesClient – upload() +// ========================================================================= +describe("FilesClient – upload()", () => { + let client: FilesClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + mockClient.config.authenticate.mockResolvedValue(undefined); + fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("handles string input", async () => { + await client.upload("file.txt", "hello world"); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining( + "/api/2.0/fs/files/Volumes/catalog/schema/vol/file.txt", + ), + expect.objectContaining({ + method: "PUT", + body: "hello world", + }), + ); + }); + + test("handles Buffer input", async () => { + const buf = Buffer.from("buffer data"); + await client.upload("file.bin", buf); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "PUT", + body: buf, + }), + ); + }); + + test("handles ReadableStream input (converts to Buffer)", async () => { + const stream = streamFromString("stream data"); + await client.upload("file.txt", stream); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "PUT", + body: expect.any(Buffer), + }), + ); + + // Verify the Buffer content is correct + const callBody = fetchSpy.mock.calls[0][1].body as Buffer; + expect(callBody.toString()).toBe("stream data"); + }); + + test("defaults overwrite to true", async () => { + await client.upload("file.txt", "data"); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("overwrite=true"); + }); + + test("sets overwrite=false when specified", async () => { + await client.upload("file.txt", "data", { overwrite: false }); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("overwrite=false"); + }); + + test("calls config.authenticate on the headers", async () => { + await client.upload("file.txt", "data"); + + expect(mockClient.config.authenticate).toHaveBeenCalledWith( + expect.any(Headers), + ); + }); + + test("builds URL from client.config.host", async () => { + await client.upload("file.txt", "data"); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch( + /^https:\/\/test\.databricks\.com\/api\/2\.0\/fs\/files/, + ); + }); + + test("throws on non-ok response", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve("Forbidden"), + }); + + await expect(client.upload("file.txt", "data")).rejects.toThrow( + "Upload failed (403): Forbidden", + ); + }); + + test("resolves absolute paths directly", async () => { + await client.upload("/Volumes/other/vol/file.txt", "data"); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("/api/2.0/fs/files/Volumes/other/vol/file.txt"); + }); +}); + +// ========================================================================= +// FilesClient – createDirectory() +// ========================================================================= +describe("FilesClient – createDirectory()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("calls client.files.createDirectory with resolved path", async () => { + mockFilesApi.createDirectory.mockResolvedValue(undefined); + + await client.createDirectory("new-dir"); + + expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol/new-dir", + }); + }); + + test("uses absolute path when provided", async () => { + mockFilesApi.createDirectory.mockResolvedValue(undefined); + + await client.createDirectory("/Volumes/other/path/new-dir"); + + expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ + directory_path: "/Volumes/other/path/new-dir", + }); + }); +}); + +// ========================================================================= +// FilesClient – delete() +// ========================================================================= +describe("FilesClient – delete()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("calls client.files.delete with resolved path", async () => { + mockFilesApi.delete.mockResolvedValue(undefined); + + await client.delete("file.txt"); + + expect(mockFilesApi.delete).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/file.txt", + }); + }); + + test("uses absolute path when provided", async () => { + mockFilesApi.delete.mockResolvedValue(undefined); + + await client.delete("/Volumes/other/file.txt"); + + expect(mockFilesApi.delete).toHaveBeenCalledWith({ + file_path: "/Volumes/other/file.txt", + }); + }); +}); + +// ========================================================================= +// FilesClient – preview() +// ========================================================================= +describe("FilesClient – preview()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("text files return truncated preview (max 1024 bytes)", async () => { + const longText = "A".repeat(2000); + + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 2000, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(longText), + }); + + const result = await client.preview("/file.txt"); + + expect(result.isText).toBe(true); + expect(result.isImage).toBe(false); + expect(result.textPreview).not.toBeNull(); + expect(result.textPreview!.length).toBeLessThanOrEqual(1024); + }); + + test("text/html files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 30, + "content-type": "text/html", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("

Hello

"), + }); + + const result = await client.preview("/page.html"); + + expect(result.isText).toBe(true); + expect(result.textPreview).toBe("

Hello

"); + }); + + test("application/json files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 20, + "content-type": "application/json", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString('{"key":"value"}'), + }); + + const result = await client.preview("/data.json"); + + expect(result.isText).toBe(true); + expect(result.textPreview).toBe('{"key":"value"}'); + }); + + test("application/xml files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 30, + "content-type": "application/xml", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(""), + }); + + const result = await client.preview("/data.xml"); + + expect(result.isText).toBe(true); + expect(result.textPreview).toBe(""); + }); + + test("image files return isImage: true, textPreview: null", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 5000, + "content-type": "image/png", + "last-modified": "2025-01-01", + }); + + const result = await client.preview("/image.png"); + + expect(result.isImage).toBe(true); + expect(result.isText).toBe(false); + expect(result.textPreview).toBeNull(); + }); + + test("other files return isText: false, isImage: false, textPreview: null", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 1000, + "content-type": "application/pdf", + "last-modified": "2025-01-01", + }); + + const result = await client.preview("/doc.pdf"); + + expect(result.isText).toBe(false); + expect(result.isImage).toBe(false); + expect(result.textPreview).toBeNull(); + }); + + test("empty file contents return empty string preview", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 0, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: null, + }); + + const result = await client.preview("/empty.txt"); + + expect(result.isText).toBe(true); + expect(result.isImage).toBe(false); + expect(result.textPreview).toBe(""); + }); + + test("preview spreads metadata into result", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 42, + "content-type": "text/plain", + "last-modified": "2025-06-15T10:00:00Z", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("hello"), + }); + + const result = await client.preview("/notes.txt"); + + expect(result.contentLength).toBe(42); + expect(result.contentType).toBe("text/plain"); + expect(result.lastModified).toBe("2025-06-15T10:00:00Z"); + expect(result.textPreview).toBe("hello"); + }); + + test("short text file returns full content", async () => { + const content = "Short file."; + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": content.length, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); + + const result = await client.preview("/short.txt"); + + expect(result.textPreview).toBe(content); + }); +}); diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts new file mode 100644 index 00000000..d768766d --- /dev/null +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -0,0 +1,354 @@ +import type { Server } from "node:http"; +import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { ServiceContext } from "../../../context/service-context"; +import { createApp } from "../../../core"; +import { server as serverPlugin } from "../../server"; +import { files } from "../index"; + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- +const { mockFilesApi, mockSdkClient, MockApiError } = vi.hoisted(() => { + const mockFilesApi = { + listDirectoryContents: vi.fn(), + download: vi.fn(), + getMetadata: vi.fn(), + upload: vi.fn(), + createDirectory: vi.fn(), + delete: vi.fn(), + }; + + const mockSdkClient = { + files: mockFilesApi, + config: { + host: "https://test.databricks.com", + authenticate: vi.fn(), + }, + currentUser: { + me: vi.fn().mockResolvedValue({ id: "test-user" }), + }, + }; + + class MockApiError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + } + } + + return { mockFilesApi, mockSdkClient, MockApiError }; +}); + +// Mock SDK so `ApiError` instanceof checks work in FilesClient.exists() +vi.mock("@databricks/sdk-experimental", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ApiError: MockApiError, + }; +}); + +// --------------------------------------------------------------------------- +// Helper: create a ReadableStream from a string +// --------------------------------------------------------------------------- +function streamFromString(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +// Headers to simulate authenticated user requests (required by asUser) +const authHeaders = { + "x-forwarded-access-token": "test-token", + "x-forwarded-user": "test-user", +}; + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- +describe("Files Plugin Integration", () => { + let server: Server; + let baseUrl: string; + let serviceContextMock: Awaited>; + const TEST_PORT = 9877; + + beforeAll(async () => { + setupDatabricksEnv(); + ServiceContext.reset(); + + serviceContextMock = await mockServiceContext({ + serviceDatabricksClient: mockSdkClient, + userDatabricksClient: mockSdkClient, + }); + + const appkit = await createApp({ + plugins: [ + serverPlugin({ + port: TEST_PORT, + host: "127.0.0.1", + autoStart: false, + }), + files({ defaultVolume: "/Volumes/catalog/schema/vol" }), + ], + }); + + // Routes are now auto-registered by the files plugin via injectRoutes + await appkit.server.start(); + server = appkit.server.getServer(); + baseUrl = `http://127.0.0.1:${TEST_PORT}`; + }); + + afterAll(async () => { + serviceContextMock?.restore(); + if (server) { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }); + + beforeEach(() => { + mockFilesApi.listDirectoryContents.mockReset(); + mockFilesApi.download.mockReset(); + mockFilesApi.getMetadata.mockReset(); + mockFilesApi.upload.mockReset(); + mockFilesApi.createDirectory.mockReset(); + mockFilesApi.delete.mockReset(); + }); + + describe("List Directory", () => { + test("GET /api/files/list returns directory entries", async () => { + const entries = [ + { + name: "file1.txt", + path: "/Volumes/catalog/schema/vol/file1.txt", + is_directory: false, + }, + { + name: "subdir", + path: "/Volumes/catalog/schema/vol/subdir", + is_directory: true, + }, + ]; + + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () { + for (const entry of entries) { + yield entry; + } + })(), + ); + + const response = await fetch(`${baseUrl}/api/files/list`, { + headers: authHeaders, + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual(entries); + }); + + test("GET /api/files/list?path=/abs/path uses provided path", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + const response = await fetch( + `${baseUrl}/api/files/list?path=/Volumes/other/path`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/other/path", + }); + }); + }); + + describe("Read File", () => { + test("GET /api/files/read?path=/file.txt returns text content", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("file content here"), + }); + + const response = await fetch( + `${baseUrl}/api/files/read?path=/Volumes/catalog/schema/vol/file.txt`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toBe("file content here"); + }); + + test("GET /api/files/read without path returns 400", async () => { + const response = await fetch(`${baseUrl}/api/files/read`, { + headers: authHeaders, + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toEqual({ error: "path is required", plugin: "files" }); + }); + }); + + describe("Exists", () => { + test("GET /api/files/exists returns { exists: true }", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 100, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + + const response = await fetch( + `${baseUrl}/api/files/exists?path=/Volumes/catalog/schema/vol/file.txt`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ exists: true }); + }); + + test("GET /api/files/exists returns { exists: false } on 404", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Not found", 404), + ); + + const response = await fetch( + `${baseUrl}/api/files/exists?path=/Volumes/missing.txt`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ exists: false }); + }); + }); + + describe("Metadata", () => { + test("GET /api/files/metadata returns correct metadata", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 256, + "content-type": "application/json", + "last-modified": "2025-06-15T10:00:00Z", + }); + + const response = await fetch( + `${baseUrl}/api/files/metadata?path=/Volumes/catalog/schema/vol/file.json`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data).toEqual({ + contentLength: 256, + contentType: "application/json", + lastModified: "2025-06-15T10:00:00Z", + }); + }); + }); + + describe("Preview", () => { + test("GET /api/files/preview returns text preview", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 20, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("Hello preview!"), + }); + + const response = await fetch( + `${baseUrl}/api/files/preview?path=/Volumes/catalog/schema/vol/file.txt`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as { + isText: boolean; + isImage: boolean; + textPreview: string | null; + }; + expect(data.isText).toBe(true); + expect(data.isImage).toBe(false); + expect(data.textPreview).toBe("Hello preview!"); + }); + + test("GET /api/files/preview returns image metadata", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 5000, + "content-type": "image/png", + "last-modified": "2025-01-01", + }); + + const response = await fetch( + `${baseUrl}/api/files/preview?path=/Volumes/catalog/schema/vol/image.png`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as { + isText: boolean; + isImage: boolean; + textPreview: string | null; + }; + expect(data.isImage).toBe(true); + expect(data.isText).toBe(false); + expect(data.textPreview).toBeNull(); + }); + }); + + describe("Error Handling", () => { + test("SDK exceptions return 500 with error message", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new Error("SDK connection failed"), + ); + + const response = await fetch( + `${baseUrl}/api/files/metadata?path=/Volumes/catalog/schema/vol/file.txt`, + { headers: authHeaders }, + ); + + expect(response.status).toBe(500); + const data = (await response.json()) as { error: string; plugin: string }; + expect(data.error).toBe("SDK connection failed"); + expect(data.plugin).toBe("files"); + }); + + test("list errors return 500", async () => { + mockFilesApi.listDirectoryContents.mockRejectedValue( + new Error("Permission denied"), + ); + + const response = await fetch(`${baseUrl}/api/files/list`, { + headers: authHeaders, + }); + + expect(response.status).toBe(500); + const data = (await response.json()) as { error: string; plugin: string }; + expect(data.error).toBe("Permission denied"); + expect(data.plugin).toBe("files"); + }); + }); +}); diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts new file mode 100644 index 00000000..049389e8 --- /dev/null +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -0,0 +1,625 @@ +import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ServiceContext } from "../../../context/service-context"; +import { contentTypeFromPath } from "../helpers"; +import { FilesClient } from "../lib"; +import { FilesPlugin, files } from "../plugin"; + +// --------------------------------------------------------------------------- +// Mock SDK + CacheManager +// --------------------------------------------------------------------------- +const { mockFilesApi, mockClient, MockApiError, mockCacheInstance } = + vi.hoisted(() => { + const mockFilesApi = { + listDirectoryContents: vi.fn(), + download: vi.fn(), + getMetadata: vi.fn(), + upload: vi.fn(), + createDirectory: vi.fn(), + delete: vi.fn(), + }; + + const mockClient = { + files: mockFilesApi, + config: { + host: "https://test.databricks.com", + authenticate: vi.fn(), + }, + }; + + class MockApiError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + } + } + + const mockCacheInstance = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise) => + fn(), + ), + generateKey: vi.fn(), + }; + + return { mockFilesApi, mockClient, MockApiError, mockCacheInstance }; + }); + +vi.mock("@databricks/sdk-experimental", () => ({ + WorkspaceClient: vi.fn(() => mockClient), + ApiError: MockApiError, +})); + +vi.mock("../../../context", () => ({ + getWorkspaceClient: vi.fn(() => mockClient), +})); + +vi.mock("../../../cache", () => ({ + CacheManager: { + getInstanceSync: vi.fn(() => mockCacheInstance), + }, +})); + +// --------------------------------------------------------------------------- +// Helper: create a ReadableStream from a string +// --------------------------------------------------------------------------- +function streamFromString(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("contentTypeFromPath", () => { + test("returns reported content-type when not application/octet-stream", () => { + expect(contentTypeFromPath("/file.txt", "text/html")).toBe("text/html"); + }); + + test("falls back to extension lookup when reported is application/octet-stream", () => { + expect(contentTypeFromPath("/image.png", "application/octet-stream")).toBe( + "image/png", + ); + }); + + test("falls back to extension lookup when no reported type", () => { + expect(contentTypeFromPath("/data.json")).toBe("application/json"); + }); + + test("returns application/octet-stream for unknown extensions with no reported type", () => { + expect(contentTypeFromPath("/file.xyz")).toBe("application/octet-stream"); + }); + + test("handles case-insensitive extensions", () => { + expect(contentTypeFromPath("/image.PNG")).toBe("image/png"); + expect(contentTypeFromPath("/data.Json")).toBe("application/json"); + }); +}); + +describe("FilesClient - Path Resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("absolute paths are returned as-is", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + + // Exercise resolvePath indirectly via download + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("/Volumes/other/path/file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/other/path/file.txt", + }); + }); + + test("relative paths prepend defaultVolume", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("subdir/file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/subdir/file.txt", + }); + }); + + test("relative path without defaultVolume throws error", () => { + const client = new FilesClient({ client: mockClient as any }); + + expect(() => client.download("file.txt")).rejects.toThrow( + "Cannot resolve relative path", + ); + }); + + test("volume() creates new client scoped to a different volume", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol1", + client: mockClient as any, + }); + + const scoped = client.volume("/Volumes/catalog/schema/vol2"); + + mockFilesApi.download.mockResolvedValue({ contents: null }); + scoped.download("file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol2/file.txt", + }); + }); +}); + +describe("FilesClient - Core Operations", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + describe("list()", () => { + test("collects async iterator entries", async () => { + const entries = [ + { + name: "file1.txt", + path: "/Volumes/catalog/schema/vol/file1.txt", + is_directory: false, + }, + { + name: "subdir", + path: "/Volumes/catalog/schema/vol/subdir", + is_directory: true, + }, + ]; + + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () { + for (const entry of entries) { + yield entry; + } + })(), + ); + + const result = await client.list(); + + expect(result).toEqual(entries); + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol", + }); + }); + + test("uses defaultVolume when no path provided", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + await client.list(); + + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol", + }); + }); + + test("throws when no path and no defaultVolume", async () => { + const noVolumeClient = new FilesClient({ client: mockClient as any }); + + await expect(noVolumeClient.list()).rejects.toThrow( + "No directory path provided and no default volume set.", + ); + }); + + test("uses provided path when given", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); + + await client.list("/Volumes/other/path"); + + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/other/path", + }); + }); + }); + + describe("read()", () => { + test("decodes ReadableStream to UTF-8 string", async () => { + const content = "Hello, world!"; + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); + + const result = await client.read("/file.txt"); + + expect(result).toBe(content); + }); + + test("returns empty string for no contents", async () => { + mockFilesApi.download.mockResolvedValue({ contents: null }); + + const result = await client.read("/empty.txt"); + + expect(result).toBe(""); + }); + }); + + describe("download()", () => { + test("calls client.files.download with resolved path", async () => { + const response = { contents: streamFromString("data") }; + mockFilesApi.download.mockResolvedValue(response); + + const result = await client.download("file.txt"); + + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/file.txt", + }); + expect(result).toBe(response); + }); + }); + + describe("exists()", () => { + test("returns true when metadata succeeds", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 100, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + + const result = await client.exists("/file.txt"); + + expect(result).toBe(true); + }); + + test("returns false on 404 ApiError", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Not found", 404), + ); + + const result = await client.exists("/missing.txt"); + + expect(result).toBe(false); + }); + + test("rethrows other errors", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Server error", 500), + ); + + await expect(client.exists("/file.txt")).rejects.toThrow("Server error"); + }); + }); + + describe("metadata()", () => { + test("maps SDK response to FileMetadata", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 1234, + "content-type": "application/json", + "last-modified": "2025-06-15T10:00:00Z", + }); + + const result = await client.metadata("/data.json"); + + expect(result).toEqual({ + contentLength: 1234, + contentType: "application/json", + lastModified: "2025-06-15T10:00:00Z", + }); + }); + + test("uses contentTypeFromPath for content-type", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 500, + "content-type": "application/octet-stream", + "last-modified": "2025-01-01", + }); + + const result = await client.metadata("/image.png"); + + expect(result.contentType).toBe("image/png"); + }); + }); + + describe("upload()", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + mockClient.config.authenticate.mockResolvedValue(undefined); + fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("handles string input", async () => { + await client.upload("file.txt", "hello world"); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining( + "/api/2.0/fs/files/Volumes/catalog/schema/vol/file.txt", + ), + expect.objectContaining({ + method: "PUT", + body: "hello world", + }), + ); + }); + + test("handles Buffer input", async () => { + const buf = Buffer.from("buffer data"); + await client.upload("file.bin", buf); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "PUT", + body: buf, + }), + ); + }); + + test("handles ReadableStream input", async () => { + const stream = streamFromString("stream data"); + await client.upload("file.txt", stream); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "PUT", + body: expect.any(Buffer), + }), + ); + }); + + test("sets overwrite param", async () => { + await client.upload("file.txt", "data", { overwrite: false }); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("overwrite=false"); + }); + + test("defaults overwrite to true", async () => { + await client.upload("file.txt", "data"); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("overwrite=true"); + }); + + test("throws on non-ok response", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve("Forbidden"), + }); + + await expect(client.upload("file.txt", "data")).rejects.toThrow( + "Upload failed (403): Forbidden", + ); + }); + }); + + describe("createDirectory()", () => { + test("calls client.files.createDirectory", async () => { + mockFilesApi.createDirectory.mockResolvedValue(undefined); + + await client.createDirectory("new-dir"); + + expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol/new-dir", + }); + }); + }); + + describe("delete()", () => { + test("calls client.files.delete", async () => { + mockFilesApi.delete.mockResolvedValue(undefined); + + await client.delete("file.txt"); + + expect(mockFilesApi.delete).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/file.txt", + }); + }); + }); +}); + +describe("FilesClient - Preview", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + }); + + test("text files return truncated preview (max 1024 bytes)", async () => { + const longText = "A".repeat(2000); + + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 2000, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(longText), + }); + + const result = await client.preview("/file.txt"); + + expect(result.isText).toBe(true); + expect(result.isImage).toBe(false); + expect(result.textPreview).not.toBeNull(); + expect(result.textPreview!.length).toBeLessThanOrEqual(1024); + }); + + test("application/json files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 20, + "content-type": "application/json", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString('{"key":"value"}'), + }); + + const result = await client.preview("/data.json"); + + expect(result.isText).toBe(true); + expect(result.textPreview).toBe('{"key":"value"}'); + }); + + test("application/xml files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 30, + "content-type": "application/xml", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(""), + }); + + const result = await client.preview("/data.xml"); + + expect(result.isText).toBe(true); + expect(result.textPreview).toBe(""); + }); + + test("image files return isImage: true, textPreview: null", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 5000, + "content-type": "image/png", + "last-modified": "2025-01-01", + }); + + const result = await client.preview("/image.png"); + + expect(result.isImage).toBe(true); + expect(result.isText).toBe(false); + expect(result.textPreview).toBeNull(); + }); + + test("other files return isText: false, isImage: false, textPreview: null", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 1000, + "content-type": "application/pdf", + "last-modified": "2025-01-01", + }); + + const result = await client.preview("/doc.pdf"); + + expect(result.isText).toBe(false); + expect(result.isImage).toBe(false); + expect(result.textPreview).toBeNull(); + }); + + test("empty file contents return empty string preview", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 0, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: null, + }); + + const result = await client.preview("/empty.txt"); + + expect(result.isText).toBe(true); + expect(result.textPreview).toBe(""); + }); +}); + +describe("FilesPlugin", () => { + let serviceContextMock: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + setupDatabricksEnv(); + ServiceContext.reset(); + serviceContextMock = await mockServiceContext(); + }); + + afterEach(() => { + serviceContextMock?.restore(); + }); + + test('plugin name is "files"', () => { + const pluginData = files({ defaultVolume: "/Volumes/test" }); + expect(pluginData.name).toBe("files"); + }); + + test("plugin instance has correct name", () => { + const plugin = new FilesPlugin({ defaultVolume: "/Volumes/test" }); + expect(plugin.name).toBe("files"); + }); + + test("exports() returns all expected methods", () => { + const plugin = new FilesPlugin({ defaultVolume: "/Volumes/test" }); + const exported = plugin.exports(); + + expect(exported).toHaveProperty("list"); + expect(exported).toHaveProperty("read"); + expect(exported).toHaveProperty("download"); + expect(exported).toHaveProperty("exists"); + expect(exported).toHaveProperty("metadata"); + expect(exported).toHaveProperty("upload"); + expect(exported).toHaveProperty("createDirectory"); + expect(exported).toHaveProperty("delete"); + expect(exported).toHaveProperty("preview"); + + // All exports should be functions + for (const value of Object.values(exported)) { + expect(typeof value).toBe("function"); + } + }); + + test("injectRoutes registers GET and POST routes", () => { + const plugin = new FilesPlugin({ defaultVolume: "/Volumes/test" }); + const mockRouter = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + } as any; + + plugin.injectRoutes(mockRouter); + + // 8 GET routes: root, list, read, download, raw, exists, metadata, preview + expect(mockRouter.get).toHaveBeenCalledTimes(8); + // 3 POST routes: upload, mkdir, delete + expect(mockRouter.post).toHaveBeenCalledTimes(3); + expect(mockRouter.put).not.toHaveBeenCalled(); + expect(mockRouter.patch).not.toHaveBeenCalled(); + }); + + test("shutdown() calls streamManager.abortAll()", async () => { + const plugin = new FilesPlugin({ defaultVolume: "/Volumes/test" }); + const abortAllSpy = vi.spyOn((plugin as any).streamManager, "abortAll"); + + await plugin.shutdown(); + + expect(abortAllSpy).toHaveBeenCalled(); + }); +}); From b27e2314510d2224428f5c91bc2a82a58dcda0f9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 12:39:32 +0100 Subject: [PATCH 04/23] feat: add stream and cache --- packages/appkit/src/plugins/files/defaults.ts | 47 +- packages/appkit/src/plugins/files/index.ts | 1 + packages/appkit/src/plugins/files/plugin.ts | 429 +++++++++++------- .../files/tests/plugin.integration.test.ts | 13 +- packages/appkit/src/plugins/server/index.ts | 13 +- packages/appkit/src/stream/stream-manager.ts | 11 + 6 files changed, 330 insertions(+), 184 deletions(-) diff --git a/packages/appkit/src/plugins/files/defaults.ts b/packages/appkit/src/plugins/files/defaults.ts index e717b333..9539444f 100644 --- a/packages/appkit/src/plugins/files/defaults.ts +++ b/packages/appkit/src/plugins/files/defaults.ts @@ -1,18 +1,39 @@ import type { PluginExecuteConfig } from "shared"; -// // TODO: Tune defaults based on actual file operation characteristics -// export const filesDefaults: PluginExecuteConfig = { -// cache: { -// enabled: false, -// ttl: 0, -// }, -// retry: { -// enabled: true, -// initialDelay: 1000, -// attempts: 3, -// }, -// timeout: 30000, -// }; +export const filesReadDefaults: PluginExecuteConfig = { + cache: { + enabled: true, + ttl: 60_000, + }, + retry: { + enabled: true, + initialDelay: 1000, + attempts: 3, + }, + timeout: 30_000, +}; + +export const filesDownloadDefaults: PluginExecuteConfig = { + cache: { + enabled: false, + }, + retry: { + enabled: true, + initialDelay: 1000, + attempts: 3, + }, + timeout: 60_000, +}; + +export const filesWriteDefaults: PluginExecuteConfig = { + cache: { + enabled: false, + }, + retry: { + enabled: false, + }, + timeout: 120_000, +}; export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ ".png": "image/png", diff --git a/packages/appkit/src/plugins/files/index.ts b/packages/appkit/src/plugins/files/index.ts index 8bb872c1..8ca86436 100644 --- a/packages/appkit/src/plugins/files/index.ts +++ b/packages/appkit/src/plugins/files/index.ts @@ -1,3 +1,4 @@ +export * from "./defaults"; export * from "./helpers"; export * from "./manifest"; export * from "./plugin"; diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 8d6a3b7e..9587dfca 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -1,13 +1,21 @@ import { Readable } from "node:stream"; import type express from "express"; -import type { IAppRouter } from "shared"; -import { getWorkspaceClient } from "../../context"; +import type { IAppRouter, PluginExecutionSettings } from "shared"; +import { getCurrentUserId, getWorkspaceClient } from "../../context"; +import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; +import { + filesDownloadDefaults, + filesReadDefaults, + filesWriteDefaults, +} from "./defaults"; import { contentTypeFromPath } from "./helpers"; import { FilesClient } from "./lib"; import { filesManifest } from "./manifest"; import type { DownloadResponse, IFilesConfig } from "./types"; +const logger = createLogger("files"); + export class FilesPlugin extends Plugin { name = "files"; @@ -179,75 +187,108 @@ export class FilesPlugin extends Plugin { // --- Private route handlers --- + private _readSettings( + cacheKey: (string | number | object)[], + ): PluginExecutionSettings { + return { + default: { + ...filesReadDefaults, + cache: { ...filesReadDefaults.cache, cacheKey }, + }, + }; + } + + /** + * Invalidate cached list entries for a directory after a write operation. + */ + private _invalidateListCache(directoryPath: string): void { + const userKey = getCurrentUserId(); + const listKey = this.cache.generateKey( + ["files:list", directoryPath], + userKey, + ); + this.cache.delete(listKey); + } + private async _handleList( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string | undefined; - const entries = await this.asUser(req).list(path); - res.json(entries); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "List failed", - plugin: this.name, - }); + const path = req.query.path as string | undefined; + const executor = this.asUser(req); + + const result = await executor.execute( + async () => executor.list(path), + this._readSettings(["files:list", path ?? "__root__"]), + ); + + if (result === undefined) { + res.status(500).json({ error: "List failed", plugin: this.name }); + return; } + res.json(result); } private async _handleRead( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - const content = await this.asUser(req).read(path); - res.type("text/plain").send(content); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Read failed", - plugin: this.name, - }); + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; } + + const executor = this.asUser(req); + const result = await executor.execute( + async () => executor.read(path), + this._readSettings(["files:read", path]), + ); + + if (result === undefined) { + res.status(500).json({ error: "Read failed", plugin: this.name }); + return; + } + res.type("text/plain").send(result); } private async _handleDownload( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - const response = await this.asUser(req).download(path); - const fileName = path.split("/").pop() ?? "download"; - res.setHeader( - "Content-Disposition", - `attachment; filename="${fileName}"`, - ); - res.setHeader( - "Content-Type", - contentTypeFromPath(path) ?? "application/octet-stream", + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + const executor = this.asUser(req); + const settings: PluginExecutionSettings = { + default: filesDownloadDefaults, + }; + const response = await executor.execute( + async () => executor.download(path), + settings, + ); + + if (response === undefined) { + res.status(500).json({ error: "Download failed", plugin: this.name }); + return; + } + + const fileName = path.split("/").pop() ?? "download"; + res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); + res.setHeader( + "Content-Type", + contentTypeFromPath(path) ?? "application/octet-stream", + ); + if (response.contents) { + const nodeStream = Readable.fromWeb( + response.contents as import("node:stream/web").ReadableStream, ); - if (response.contents) { - const nodeStream = Readable.fromWeb( - response.contents as import("node:stream/web").ReadableStream, - ); - nodeStream.pipe(res); - } else { - res.end(); - } - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Download failed", - plugin: this.name, - }); + nodeStream.pipe(res); + } else { + res.end(); } } @@ -255,30 +296,37 @@ export class FilesPlugin extends Plugin { req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - const response = await this.asUser(req).download(path); - res.setHeader( - "Content-Type", - contentTypeFromPath(path) ?? "application/octet-stream", + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + const executor = this.asUser(req); + const settings: PluginExecutionSettings = { + default: filesDownloadDefaults, + }; + const response = await executor.execute( + async () => executor.download(path), + settings, + ); + + if (response === undefined) { + res.status(500).json({ error: "Raw fetch failed", plugin: this.name }); + return; + } + + res.setHeader( + "Content-Type", + contentTypeFromPath(path) ?? "application/octet-stream", + ); + if (response.contents) { + const nodeStream = Readable.fromWeb( + response.contents as import("node:stream/web").ReadableStream, ); - if (response.contents) { - const nodeStream = Readable.fromWeb( - response.contents as import("node:stream/web").ReadableStream, - ); - nodeStream.pipe(res); - } else { - res.end(); - } - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Raw fetch failed", - plugin: this.name, - }); + nodeStream.pipe(res); + } else { + res.end(); } } @@ -286,133 +334,186 @@ export class FilesPlugin extends Plugin { req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - const exists = await this.asUser(req).exists(path); - res.json({ exists }); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Exists check failed", - plugin: this.name, - }); + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + const executor = this.asUser(req); + const result = await executor.execute( + async () => executor.exists(path), + this._readSettings(["files:exists", path]), + ); + + if (result === undefined) { + res.status(500).json({ error: "Exists check failed", plugin: this.name }); + return; } + res.json({ exists: result }); } private async _handleMetadata( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - const metadata = await this.asUser(req).metadata(path); - res.json(metadata); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Metadata fetch failed", - plugin: this.name, - }); + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + const executor = this.asUser(req); + const result = await executor.execute( + async () => executor.metadata(path), + this._readSettings(["files:metadata", path]), + ); + + if (result === undefined) { + res + .status(500) + .json({ error: "Metadata fetch failed", plugin: this.name }); + return; } + res.json(result); } private async _handlePreview( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - const preview = await this.asUser(req).preview(path); - res.json(preview); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Preview failed", - plugin: this.name, - }); + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + const executor = this.asUser(req); + const result = await executor.execute( + async () => executor.preview(path), + this._readSettings(["files:preview", path]), + ); + + if (result === undefined) { + res.status(500).json({ error: "Preview failed", plugin: this.name }); + return; } + res.json(result); } private async _handleUpload( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + logger.debug(req, "Upload started: path=%s", path); + + const body = await new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", async () => { - try { - const body = Buffer.concat(chunks); - await this.asUser(req).upload(path, body); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Upload failed", - plugin: this.name, - }); - } - }); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Upload failed", - plugin: this.name, - }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); + + logger.debug( + req, + "Upload body received: path=%s, size=%d bytes", + path, + body.length, + ); + + const executor = this.asUser(req); + const settings: PluginExecutionSettings = { + default: filesWriteDefaults, + }; + const result = await executor.execute(async () => { + await executor.upload(path, body); + return { success: true as const }; + }, settings); + + if (result === undefined) { + logger.error( + req, + "Upload failed: path=%s, size=%d bytes", + path, + body.length, + ); + res.status(500).json({ error: "Upload failed", plugin: this.name }); + return; } + + const parentDir = path.substring(0, path.lastIndexOf("/")) || path; + this._invalidateListCache(parentDir); + + logger.debug(req, "Upload complete: path=%s", path); + res.json(result); } private async _handleMkdir( req: express.Request, res: express.Response, ): Promise { - try { - const dirPath = req.body?.path as string; - if (!dirPath) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - await this.asUser(req).createDirectory(dirPath); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ - error: - error instanceof Error ? error.message : "Create directory failed", - plugin: this.name, - }); + const dirPath = req.body?.path as string; + if (!dirPath) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; } + + const executor = this.asUser(req); + const settings: PluginExecutionSettings = { + default: filesWriteDefaults, + }; + const result = await executor.execute(async () => { + await executor.createDirectory(dirPath); + return { success: true as const }; + }, settings); + + if (result === undefined) { + res + .status(500) + .json({ error: "Create directory failed", plugin: this.name }); + return; + } + + const parentDir = dirPath.substring(0, dirPath.lastIndexOf("/")) || dirPath; + this._invalidateListCache(parentDir); + + res.json(result); } private async _handleDelete( req: express.Request, res: express.Response, ): Promise { - try { - const path = req.query.path as string; - if (!path) { - res.status(400).json({ error: "path is required", plugin: this.name }); - return; - } - await this.asUser(req).delete(path); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : "Delete failed", - plugin: this.name, - }); + const path = req.query.path as string; + if (!path) { + res.status(400).json({ error: "path is required", plugin: this.name }); + return; + } + + const executor = this.asUser(req); + const settings: PluginExecutionSettings = { + default: filesWriteDefaults, + }; + const result = await executor.execute(async () => { + await executor.delete(path); + return { success: true as const }; + }, settings); + + if (result === undefined) { + res.status(500).json({ error: "Delete failed", plugin: this.name }); + return; } + + const parentDir = path.substring(0, path.lastIndexOf("/")) || path; + this._invalidateListCache(parentDir); + + res.json(result); } async shutdown(): Promise { diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index d768766d..85e40fbc 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -320,7 +320,7 @@ describe("Files Plugin Integration", () => { }); describe("Error Handling", () => { - test("SDK exceptions return 500 with error message", async () => { + test("SDK exceptions return 500 with generic error", async () => { mockFilesApi.getMetadata.mockRejectedValue( new Error("SDK connection failed"), ); @@ -332,7 +332,7 @@ describe("Files Plugin Integration", () => { expect(response.status).toBe(500); const data = (await response.json()) as { error: string; plugin: string }; - expect(data.error).toBe("SDK connection failed"); + expect(data.error).toBe("Metadata fetch failed"); expect(data.plugin).toBe("files"); }); @@ -341,13 +341,14 @@ describe("Files Plugin Integration", () => { new Error("Permission denied"), ); - const response = await fetch(`${baseUrl}/api/files/list`, { - headers: authHeaders, - }); + const response = await fetch( + `${baseUrl}/api/files/list?path=/Volumes/uncached/path`, + { headers: authHeaders }, + ); expect(response.status).toBe(500); const data = (await response.json()) as { error: string; plugin: string }; - expect(data.error).toBe("Permission denied"); + expect(data.error).toBe("List failed"); expect(data.plugin).toBe("files"); }); }); diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 40cf01e0..407a4c58 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -92,7 +92,18 @@ export class ServerPlugin extends Plugin { * @returns The express application. */ async start(): Promise { - this.serverApplication.use(express.json()); + this.serverApplication.use( + express.json({ + type: (req) => { + // Skip JSON parsing for file upload routes so raw body + // data flows through to the handler (express.json default + // limit is 100KB which silently drops larger payloads). + if (req.url?.includes("/upload")) return false; + const ct = req.headers["content-type"] ?? ""; + return ct.includes("json"); + }, + }), + ); const endpoints = await this.extendRoutes(); diff --git a/packages/appkit/src/stream/stream-manager.ts b/packages/appkit/src/stream/stream-manager.ts index 41764772..e92f5818 100644 --- a/packages/appkit/src/stream/stream-manager.ts +++ b/packages/appkit/src/stream/stream-manager.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { context } from "@opentelemetry/api"; import type { IAppResponse, StreamConfig } from "shared"; +import { createLogger } from "../logging/logger"; import { EventRingBuffer } from "./buffers"; import { streamDefaults } from "./defaults"; import { SSEWriter } from "./sse-writer"; @@ -8,6 +9,8 @@ import { StreamRegistry } from "./stream-registry"; import { SSEErrorCode, type StreamEntry, type StreamOperation } from "./types"; import { StreamValidator } from "./validator"; +const logger = createLogger("stream"); + // main entry point for Server-Sent events streaming export class StreamManager { private activeOperations: Set; @@ -76,6 +79,12 @@ export class StreamManager { streamEntry: StreamEntry, options?: StreamConfig, ): Promise { + logger.debug( + "Client reconnecting to stream: streamId=%s, clients=%d", + streamEntry.streamId, + streamEntry.clients.size, + ); + // handle reconnection - replay missed events const lastEventId = res.req?.headers["last-event-id"]; @@ -187,6 +196,8 @@ export class StreamManager { }; this.streamRegistry.add(streamEntry); + logger.debug("New stream created: streamId=%s", streamId); + // track operation const streamOperation: StreamOperation = { controller: abortController, From ece130a5113a25f3deb07b6db7411a9369021edc Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 15:09:52 +0100 Subject: [PATCH 05/23] chore: file upload stream --- packages/appkit/src/plugins/files/README.md | 109 +++++++++++++++++- packages/appkit/src/plugins/files/defaults.ts | 7 +- packages/appkit/src/plugins/files/lib.ts | 47 ++++---- packages/appkit/src/plugins/files/plugin.ts | 24 ++-- .../src/plugins/files/tests/lib.test.ts | 9 +- .../files/tests/plugin.integration.test.ts | 2 +- .../src/plugins/files/tests/plugin.test.ts | 3 +- 7 files changed, 152 insertions(+), 49 deletions(-) diff --git a/packages/appkit/src/plugins/files/README.md b/packages/appkit/src/plugins/files/README.md index 7a9922f0..13830d6d 100644 --- a/packages/appkit/src/plugins/files/README.md +++ b/packages/appkit/src/plugins/files/README.md @@ -1,8 +1,28 @@ # Files Plugin -The files plugin provides HTTP routes for Databricks Unity Catalog volume file operations. +The files plugin provides HTTP routes and a programmatic API for Databricks Unity Catalog volume file operations. It supports listing, reading, downloading, uploading, deleting, and previewing files with built-in caching, retry, and timeout handling via the execution interceptor pipeline. + Routes are automatically registered via `injectRoutes` and mounted at `/api/files/*`. +## Configuration + +The plugin accepts an `IFilesConfig` object: + +```ts +interface IFilesConfig { + timeout?: number; // Operation timeout in ms + defaultVolume?: string; // Absolute volume path, e.g. "/Volumes/catalog/schema/vol" +} +``` + +Usage with the `files()` factory: + +```ts +import { files } from "@databricks/appkit"; + +files({ defaultVolume: "/Volumes/catalog/schema/vol" }); +``` + ## Routes All routes (except `/root`) execute in user context via `asUser(req)`. @@ -13,7 +33,7 @@ All routes (except `/root`) execute in user context via `asUser(req)`. | GET | `/list` | `?path` (optional) | `DirectoryEntry[]` | `list()` | | GET | `/read` | `?path` (required) | `text/plain` body | `read()` | | GET | `/download` | `?path` (required) | Binary stream (`Content-Disposition: attachment`) | `download()` | -| GET | `/raw` | `?path` (required) | Binary stream (inline) | `download()` | +| GET | `/raw` | `?path` (required) | Binary stream (inline, no Content-Disposition) | `download()` (inline) | | GET | `/exists` | `?path` (required) | `{ exists: boolean }` | `exists()` | | GET | `/metadata` | `?path` (required) | `FileMetadata` | `metadata()` | | GET | `/preview` | `?path` (required) | `FilePreview` | `preview()` | @@ -21,7 +41,88 @@ All routes (except `/root`) execute in user context via `asUser(req)`. | POST | `/mkdir` | `body.path` (required) | `{ success: true }` | `createDirectory()` | | POST | `/delete` | `?path` (required) | `{ success: true }` | `delete()` | -## Error responses +## Execution Defaults + +Every operation runs through the interceptor pipeline with tier-specific defaults: + +| Tier | Cache | Retry | Timeout | Operations | +| ---------- | ------- | ----- | ------- | ---------------------------------------- | +| **Read** | 60 s | 3× | 30 s | list, read, exists, metadata, preview | +| **Download** | none | 3× | 30 s | download, raw | +| **Write** | none | none | 600 s | upload, mkdir, delete | + +Retry uses exponential backoff with a 1 s initial delay. + +## Cache Invalidation + +Write operations (`upload`, `mkdir`, `delete`) automatically invalidate the cached `list` entry for the parent directory so subsequent listings reflect the change. + +## Types + +```ts +// Plugin configuration +interface IFilesConfig { + timeout?: number; + defaultVolume?: string; +} + +// Re-exported from @databricks/sdk-experimental +type DirectoryEntry = files.DirectoryEntry; +type DownloadResponse = files.DownloadResponse; + +// File metadata returned by /metadata +interface FileMetadata { + contentLength: number | undefined; + contentType: string | undefined; + lastModified: string | undefined; +} + +// File preview returned by /preview (extends FileMetadata) +interface FilePreview extends FileMetadata { + textPreview: string | null; // First 1 KB of text content, or null for non-text + isText: boolean; + isImage: boolean; +} +``` + +## `exports()` API + +The programmatic API returned by `exports()` for server-side use: + +| Method | Signature | Returns | +| ------------------- | ---------------------------------------------------------------------- | ------------------------ | +| `list` | `(directoryPath?: string)` | `DirectoryEntry[]` | +| `read` | `(filePath: string)` | `string` | +| `download` | `(filePath: string)` | `DownloadResponse` | +| `exists` | `(filePath: string)` | `boolean` | +| `metadata` | `(filePath: string)` | `FileMetadata` | +| `upload` | `(filePath: string, contents: ReadableStream \| Buffer \| string, options?: { overwrite?: boolean })` | `void` | +| `createDirectory` | `(directoryPath: string)` | `void` | +| `delete` | `(filePath: string)` | `void` | +| `preview` | `(filePath: string, options?: { maxBytes?: number })` | `FilePreview` | + +## Path Resolution + +Paths can be **absolute** or **relative**: + +- **Absolute** — starts with `/`, used as-is (e.g. `/Volumes/catalog/schema/vol/data.csv`) +- **Relative** — prepended with `defaultVolume` (e.g. `data.csv` → `/Volumes/catalog/schema/vol/data.csv`) + +If a relative path is used and no `defaultVolume` is configured, an error is thrown. + +The `list()` method with no arguments lists the `defaultVolume` root. + +## Content-Type Resolution + +`contentTypeFromPath(filePath, reported?)` resolves a file's content type: + +1. If the server reports a content type other than `application/octet-stream`, use it. +2. Otherwise, match the file extension against a built-in map. +3. Fall back to the reported type or `application/octet-stream`. + +Supported extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.bmp`, `.ico`, `.json`, `.xml`, `.html`, `.css`, `.js`, `.txt`, `.md`, `.csv`, `.pdf`. + +## Error Responses All errors return JSON with the shape: @@ -39,6 +140,6 @@ All errors return JSON with the shape: | 400 | Missing required `path` parameter | | 500 | Operation failed (SDK or network error) | -## User context +## User Context Routes use `this.asUser(req)` which wraps the plugin's `getFilesClient()` so that the underlying `getWorkspaceClient()` returns a client scoped to the requesting user's Databricks credentials (on-behalf-of / OBO). The `/root` route is the only exception since it only reads plugin config. diff --git a/packages/appkit/src/plugins/files/defaults.ts b/packages/appkit/src/plugins/files/defaults.ts index 9539444f..80d54d5a 100644 --- a/packages/appkit/src/plugins/files/defaults.ts +++ b/packages/appkit/src/plugins/files/defaults.ts @@ -22,7 +22,10 @@ export const filesDownloadDefaults: PluginExecuteConfig = { initialDelay: 1000, attempts: 3, }, - timeout: 60_000, + /** + * @info this timeout is for the stream to start, not for the full download. + */ + timeout: 30_000, }; export const filesWriteDefaults: PluginExecuteConfig = { @@ -32,7 +35,7 @@ export const filesWriteDefaults: PluginExecuteConfig = { retry: { enabled: false, }, - timeout: 120_000, + timeout: 600_000, }; export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ diff --git a/packages/appkit/src/plugins/files/lib.ts b/packages/appkit/src/plugins/files/lib.ts index 0ddff1c8..acc2daa4 100644 --- a/packages/appkit/src/plugins/files/lib.ts +++ b/packages/appkit/src/plugins/files/lib.ts @@ -1,4 +1,5 @@ import { ApiError, WorkspaceClient } from "@databricks/sdk-experimental"; +import { createLogger } from "@/logging/logger"; import { contentTypeFromPath } from "./helpers"; import type { DirectoryEntry, @@ -7,6 +8,8 @@ import type { FilePreview, } from "./types"; +const logger = createLogger("files"); + export class FilesClient { private client: WorkspaceClient; private defaultVolume: string | undefined; @@ -107,30 +110,16 @@ export class FilesClient { contents: ReadableStream | Buffer | string, options?: { overwrite?: boolean }, ): Promise { + const body = contents; + + const resolvedPath = this.resolvePath(filePath); + const overwrite = options?.overwrite ?? true; + // Workaround: The SDK's files.upload() has two bugs: // 1. It ignores the `contents` field (sets body to undefined) // 2. apiClient.request() checks `instanceof` against its own ReadableStream // subclass, so standard ReadableStream instances get JSON.stringified to "{}" // Bypass both by calling the REST API directly with SDK-provided auth. - let body: Buffer | string; - if (typeof contents === "string") { - body = contents; - } else if (Buffer.isBuffer(contents)) { - body = contents; - } else { - // ReadableStream → Buffer - const reader = (contents as ReadableStream).getReader(); - const chunks: Uint8Array[] = []; - let result = await reader.read(); - while (!result.done) { - chunks.push(result.value); - result = await reader.read(); - } - body = Buffer.concat(chunks); - } - - const resolvedPath = this.resolvePath(filePath); - const overwrite = options?.overwrite ?? true; const url = new URL( `/api/2.0/fs/files${resolvedPath}`, this.client.config.host, @@ -138,16 +127,19 @@ export class FilesClient { url.searchParams.set("overwrite", String(overwrite)); const headers = new Headers({ "Content-Type": "application/octet-stream" }); + const fetchOptions: RequestInit = { method: "PUT", headers, body }; + + if (body instanceof ReadableStream) { + fetchOptions.duplex = "half"; + } + await this.client.config.authenticate(headers); - const res = await fetch(url.toString(), { - method: "PUT", - headers, - body, - }); + const res = await fetch(url.toString(), fetchOptions); if (!res.ok) { const text = await res.text(); + logger.error(`Upload failed (${res.status}): ${text}`); throw new Error(`Upload failed (${res.status}): ${text}`); } } @@ -164,7 +156,10 @@ export class FilesClient { }); } - async preview(filePath: string): Promise { + async preview( + filePath: string, + options?: { maxBytes?: number }, + ): Promise { const meta = await this.metadata(filePath); const isText = meta.contentType?.startsWith("text/") || @@ -187,7 +182,7 @@ export class FilesClient { const reader = response.contents.getReader(); const decoder = new TextDecoder(); let preview = ""; - const maxBytes = 1024; + const maxBytes = options?.maxBytes ?? 1024; while (preview.length < maxBytes) { const { done, value } = await reader.read(); diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 9587dfca..9d5a1583 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -413,18 +413,22 @@ export class FilesPlugin extends Plugin { logger.debug(req, "Upload started: path=%s", path); - const body = await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => resolve(Buffer.concat(chunks))); - req.on("error", reject); - }); + // const body = await new Promise((resolve, reject) => { + // const chunks: Buffer[] = []; + // req.on("data", (chunk: Buffer) => chunks.push(chunk)); + // req.on("end", () => resolve(Buffer.concat(chunks))); + // req.on("error", reject); + // }); + + const webStream: ReadableStream = Readable.toWeb(req); logger.debug( req, "Upload body received: path=%s, size=%d bytes", path, - body.length, + req.headers["content-length"] + ? parseInt(req.headers["content-length"], 10) + : 0, ); const executor = this.asUser(req); @@ -432,7 +436,7 @@ export class FilesPlugin extends Plugin { default: filesWriteDefaults, }; const result = await executor.execute(async () => { - await executor.upload(path, body); + await executor.upload(path, webStream); return { success: true as const }; }, settings); @@ -441,7 +445,9 @@ export class FilesPlugin extends Plugin { req, "Upload failed: path=%s, size=%d bytes", path, - body.length, + req.headers["content-length"] + ? parseInt(req.headers["content-length"], 10) + : 0, ); res.status(500).json({ error: "Upload failed", plugin: this.name }); return; diff --git a/packages/appkit/src/plugins/files/tests/lib.test.ts b/packages/appkit/src/plugins/files/tests/lib.test.ts index 0f04eaec..b8e94033 100644 --- a/packages/appkit/src/plugins/files/tests/lib.test.ts +++ b/packages/appkit/src/plugins/files/tests/lib.test.ts @@ -556,7 +556,7 @@ describe("FilesClient – upload()", () => { ); }); - test("handles ReadableStream input (converts to Buffer)", async () => { + test("handles ReadableStream input (streams directly)", async () => { const stream = streamFromString("stream data"); await client.upload("file.txt", stream); @@ -564,13 +564,10 @@ describe("FilesClient – upload()", () => { expect.any(String), expect.objectContaining({ method: "PUT", - body: expect.any(Buffer), + body: expect.any(ReadableStream), + duplex: "half", }), ); - - // Verify the Buffer content is correct - const callBody = fetchSpy.mock.calls[0][1].body as Buffer; - expect(callBody.toString()).toBe("stream data"); }); test("defaults overwrite to true", async () => { diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index 85e40fbc..ef942aca 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -86,7 +86,7 @@ describe("Files Plugin Integration", () => { let server: Server; let baseUrl: string; let serviceContextMock: Awaited>; - const TEST_PORT = 9877; + const TEST_PORT = 9880; beforeAll(async () => { setupDatabricksEnv(); diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index 049389e8..979f84f5 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -385,7 +385,8 @@ describe("FilesClient - Core Operations", () => { expect.any(String), expect.objectContaining({ method: "PUT", - body: expect.any(Buffer), + body: expect.any(ReadableStream), + duplex: "half", }), ); }); From 5d85548ed821813ad3d590818cb33a6039bf0e63 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 16:12:14 +0100 Subject: [PATCH 06/23] chore: deduplicate tests, cleanup --- packages/appkit/src/plugins/files/helpers.ts | 11 +- .../src/plugins/files/tests/helpers.test.ts | 39 + .../src/plugins/files/tests/lib.test.ts | 1200 ++++++++--------- .../src/plugins/files/tests/plugin.test.ts | 499 +------ .../appkit/src/plugins/files/tests/utils.ts | 22 + 5 files changed, 625 insertions(+), 1146 deletions(-) create mode 100644 packages/appkit/src/plugins/files/tests/helpers.test.ts create mode 100644 packages/appkit/src/plugins/files/tests/utils.ts diff --git a/packages/appkit/src/plugins/files/helpers.ts b/packages/appkit/src/plugins/files/helpers.ts index cfe042e1..f0160b02 100644 --- a/packages/appkit/src/plugins/files/helpers.ts +++ b/packages/appkit/src/plugins/files/helpers.ts @@ -4,9 +4,12 @@ export function contentTypeFromPath( filePath: string, reported?: string, ): string { - if (reported && reported !== "application/octet-stream") { - return reported; - } const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); - return EXTENSION_CONTENT_TYPES[ext] ?? reported ?? "application/octet-stream"; + const fromExt = EXTENSION_CONTENT_TYPES[ext]; + + if (fromExt) { + return fromExt; + } + + return reported ?? "application/octet-stream"; } diff --git a/packages/appkit/src/plugins/files/tests/helpers.test.ts b/packages/appkit/src/plugins/files/tests/helpers.test.ts new file mode 100644 index 00000000..21bdd65d --- /dev/null +++ b/packages/appkit/src/plugins/files/tests/helpers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { contentTypeFromPath } from "../helpers"; + +describe("contentTypeFromPath", () => { + test("works without reported type", () => { + expect(contentTypeFromPath("/data.json")).toBe("application/json"); + }); + + test("returns application/octet-stream for unknown extensions with no reported type", () => { + expect(contentTypeFromPath("/file.xyz")).toBe("application/octet-stream"); + }); + + test("handles case-insensitive extensions", () => { + expect(contentTypeFromPath("/image.PNG")).toBe("image/png"); + expect(contentTypeFromPath("/data.Json")).toBe("application/json"); + }); + + test("uses extension when reported is undefined", () => { + expect(contentTypeFromPath("/style.css", undefined)).toBe("text/css"); + }); + + test("prefers extension type over reported type for known extensions", () => { + // Extension takes priority to prevent MIME type mismatch attacks + expect(contentTypeFromPath("/file.json", "text/html")).toBe( + "application/json", + ); + }); + + test("falls back to reported type for unknown extensions", () => { + expect(contentTypeFromPath("/file.xyz", "text/plain")).toBe("text/plain"); + }); + + test("handles paths with multiple dots", () => { + expect(contentTypeFromPath("/archive.tar.gz")).toBe( + "application/octet-stream", + ); + expect(contentTypeFromPath("/data.backup.json")).toBe("application/json"); + }); +}); diff --git a/packages/appkit/src/plugins/files/tests/lib.test.ts b/packages/appkit/src/plugins/files/tests/lib.test.ts index b8e94033..b9459c5e 100644 --- a/packages/appkit/src/plugins/files/tests/lib.test.ts +++ b/packages/appkit/src/plugins/files/tests/lib.test.ts @@ -1,854 +1,758 @@ +import type { WorkspaceClient } from "@databricks/sdk-experimental"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { contentTypeFromPath } from "../helpers"; import { FilesClient } from "../lib"; - -// --------------------------------------------------------------------------- -// Mock SDK -// --------------------------------------------------------------------------- -const { mockFilesApi, mockClient, MockApiError } = vi.hoisted(() => { - const mockFilesApi = { - listDirectoryContents: vi.fn(), - download: vi.fn(), - getMetadata: vi.fn(), - upload: vi.fn(), - createDirectory: vi.fn(), - delete: vi.fn(), - }; - - const mockClient = { - files: mockFilesApi, - config: { +import { streamFromChunks, streamFromString } from "./utils"; + +const { mockFilesApi, mockConfig, mockClient, MockApiError } = vi.hoisted( + () => { + const mockFilesApi = { + listDirectoryContents: vi.fn(), + download: vi.fn(), + getMetadata: vi.fn(), + upload: vi.fn(), + createDirectory: vi.fn(), + delete: vi.fn(), + }; + + const mockConfig = { host: "https://test.databricks.com", authenticate: vi.fn(), - }, - }; - - class MockApiError extends Error { - statusCode: number; - constructor(message: string, statusCode: number) { - super(message); - this.name = "ApiError"; - this.statusCode = statusCode; + }; + + const mockClient = { + files: mockFilesApi, + config: mockConfig, + } as unknown as WorkspaceClient; + + class MockApiError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + } } - } - return { mockFilesApi, mockClient, MockApiError }; -}); + return { mockFilesApi, mockConfig, mockClient, MockApiError }; + }, +); vi.mock("@databricks/sdk-experimental", () => ({ WorkspaceClient: vi.fn(() => mockClient), ApiError: MockApiError, })); -// --------------------------------------------------------------------------- -// Helper: create a ReadableStream from a string -// --------------------------------------------------------------------------- -function streamFromString(text: string): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(text)); - controller.close(); - }, - }); -} - -// Creates a ReadableStream that yields multiple chunks -function streamFromChunks(chunks: string[]): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); - } - controller.close(); - }, - }); -} - -// ========================================================================= -// contentTypeFromPath -// ========================================================================= -describe("contentTypeFromPath", () => { - test("returns reported content-type when not application/octet-stream", () => { - expect(contentTypeFromPath("/file.txt", "text/html")).toBe("text/html"); - }); - - test("falls back to extension lookup when reported is application/octet-stream", () => { - expect(contentTypeFromPath("/image.png", "application/octet-stream")).toBe( - "image/png", - ); - }); - - test("falls back to extension lookup when no reported type", () => { - expect(contentTypeFromPath("/data.json")).toBe("application/json"); - }); - - test("returns application/octet-stream for unknown extensions with no reported type", () => { - expect(contentTypeFromPath("/file.xyz")).toBe("application/octet-stream"); - }); - - test("handles case-insensitive extensions", () => { - expect(contentTypeFromPath("/image.PNG")).toBe("image/png"); - expect(contentTypeFromPath("/data.Json")).toBe("application/json"); - }); - - test("uses extension when reported is undefined", () => { - expect(contentTypeFromPath("/style.css", undefined)).toBe("text/css"); - }); - - test("returns reported type for known extensions when reported differs", () => { - // If the server says it's text/html, trust it even for a .json file - expect(contentTypeFromPath("/file.json", "text/html")).toBe("text/html"); - }); +describe("FilesClient", () => { + describe("Path Resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - test("handles paths with multiple dots", () => { - expect(contentTypeFromPath("/archive.tar.gz")).toBe( - "application/octet-stream", - ); - expect(contentTypeFromPath("/data.backup.json")).toBe("application/json"); - }); -}); + test("absolute paths are returned as-is", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient, + }); -// ========================================================================= -// FilesClient – Path Resolution -// ========================================================================= -describe("FilesClient – Path Resolution", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("/Volumes/other/path/file.txt"); - test("absolute paths are returned as-is", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/other/path/file.txt", + }); }); - mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("/Volumes/other/path/file.txt"); + test("relative paths prepend defaultVolume", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient, + }); - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/other/path/file.txt", - }); - }); + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("subdir/file.txt"); - test("relative paths prepend defaultVolume", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/subdir/file.txt", + }); }); - mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("subdir/file.txt"); + test("relative path without defaultVolume throws error", async () => { + const client = new FilesClient({ client: mockClient as any }); - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/subdir/file.txt", + await expect(client.download("file.txt")).rejects.toThrow( + "Cannot resolve relative path: no default volume set.", + ); }); - }); - - test("relative path without defaultVolume throws error", async () => { - const client = new FilesClient({ client: mockClient as any }); - - await expect(client.download("file.txt")).rejects.toThrow( - "Cannot resolve relative path: no default volume set.", - ); - }); - test("volume() creates new client scoped to a different volume", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol1", - client: mockClient as any, - }); + test("volume() creates new client scoped to a different volume", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol1", + client: mockClient as any, + }); - const scoped = client.volume("/Volumes/catalog/schema/vol2"); + const scoped = client.volume("/Volumes/catalog/schema/vol2"); - mockFilesApi.download.mockResolvedValue({ contents: null }); - scoped.download("file.txt"); + mockFilesApi.download.mockResolvedValue({ contents: null }); + scoped.download("file.txt"); - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol2/file.txt", + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol2/file.txt", + }); }); - }); - test("volume() does not affect the original client", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol1", - client: mockClient as any, - }); + test("volume() does not affect the original client", () => { + const client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol1", + client: mockClient, + }); - client.volume("/Volumes/catalog/schema/vol2"); + client.volume("/Volumes/catalog/schema/vol2"); - mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("file.txt"); + mockFilesApi.download.mockResolvedValue({ contents: null }); + client.download("file.txt"); - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol1/file.txt", + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol1/file.txt", + }); }); - }); - test("constructor without defaultVolume omits it", async () => { - const client = new FilesClient({ client: mockClient as any }); + test("constructor without defaultVolume omits it", async () => { + const client = new FilesClient({ client: mockClient as any }); - await expect(client.list()).rejects.toThrow( - "No directory path provided and no default volume set.", - ); + await expect(client.list()).rejects.toThrow( + "No directory path provided and no default volume set.", + ); + }); }); -}); -// ========================================================================= -// FilesClient – list() -// ========================================================================= -describe("FilesClient – list()", () => { - let client: FilesClient; + describe("list()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("collects async iterator entries", async () => { - const entries = [ - { - name: "file1.txt", - path: "/Volumes/catalog/schema/vol/file1.txt", - is_directory: false, - }, - { - name: "subdir", - path: "/Volumes/catalog/schema/vol/subdir", - is_directory: true, - }, - ]; - - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () { - for (const entry of entries) { - yield entry; - } - })(), - ); - - const result = await client.list(); - - expect(result).toEqual(entries); - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol", - }); - }); + test("collects async iterator entries", async () => { + const entries = [ + { + name: "file1.txt", + path: "/Volumes/catalog/schema/vol/file1.txt", + is_directory: false, + }, + { + name: "subdir", + path: "/Volumes/catalog/schema/vol/subdir", + is_directory: true, + }, + ]; - test("uses defaultVolume when no path provided", async () => { - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () {})(), - ); + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () { + for (const entry of entries) { + yield entry; + } + })(), + ); - await client.list(); + const result = await client.list(); - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol", + expect(result).toEqual(entries); + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol", + }); }); - }); - test("throws when no path and no defaultVolume", async () => { - const noVolumeClient = new FilesClient({ client: mockClient as any }); + test("uses defaultVolume when no path provided", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); - await expect(noVolumeClient.list()).rejects.toThrow( - "No directory path provided and no default volume set.", - ); - }); + await client.list(); - test("uses provided absolute path", async () => { - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () {})(), - ); + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol", + }); + }); - await client.list("/Volumes/other/path"); + test("throws when no path and no defaultVolume", async () => { + const noVolumeClient = new FilesClient({ client: mockClient as any }); - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/other/path", + await expect(noVolumeClient.list()).rejects.toThrow( + "No directory path provided and no default volume set.", + ); }); - }); - test("resolves relative path with defaultVolume", async () => { - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () {})(), - ); + test("uses provided absolute path", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); - await client.list("subdir"); + await client.list("/Volumes/other/path"); - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol/subdir", + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/other/path", + }); }); - }); - test("returns empty array for empty directory", async () => { - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () {})(), - ); + test("resolves relative path with defaultVolume", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); - const result = await client.list(); + await client.list("subdir"); - expect(result).toEqual([]); - }); -}); + expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol/subdir", + }); + }); -// ========================================================================= -// FilesClient – read() -// ========================================================================= -describe("FilesClient – read()", () => { - let client: FilesClient; + test("returns empty array for empty directory", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () {})(), + ); - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + const result = await client.list(); + + expect(result).toEqual([]); }); }); - test("decodes ReadableStream to UTF-8 string", async () => { - const content = "Hello, world!"; - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(content), + describe("read()", () => { + let client: FilesClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - const result = await client.read("/file.txt"); + test("decodes ReadableStream to UTF-8 string", async () => { + const content = "Hello, world!"; + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); - expect(result).toBe(content); - }); + const result = await client.read("/file.txt"); - test("returns empty string when contents is null", async () => { - mockFilesApi.download.mockResolvedValue({ contents: null }); + expect(result).toBe(content); + }); - const result = await client.read("/empty.txt"); + test("returns empty string when contents is null", async () => { + mockFilesApi.download.mockResolvedValue({ contents: null }); - expect(result).toBe(""); - }); + const result = await client.read("/empty.txt"); - test("concatenates multiple chunks correctly", async () => { - mockFilesApi.download.mockResolvedValue({ - contents: streamFromChunks(["Hello, ", "world", "!"]), + expect(result).toBe(""); }); - const result = await client.read("/chunked.txt"); + test("concatenates multiple chunks correctly", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromChunks(["Hello, ", "world", "!"]), + }); - expect(result).toBe("Hello, world!"); - }); + const result = await client.read("/chunked.txt"); - test("handles multi-byte UTF-8 characters", async () => { - const content = "Héllo wörld 🌍"; - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(content), + expect(result).toBe("Hello, world!"); }); - const result = await client.read("/unicode.txt"); + test("handles multi-byte UTF-8 characters", async () => { + const content = "Héllo wörld 🌍"; + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); - expect(result).toBe(content); + const result = await client.read("/unicode.txt"); + + expect(result).toBe(content); + }); }); -}); -// ========================================================================= -// FilesClient – download() -// ========================================================================= -describe("FilesClient – download()", () => { - let client: FilesClient; + describe("download()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("calls client.files.download with resolved path", async () => { - const response = { contents: streamFromString("data") }; - mockFilesApi.download.mockResolvedValue(response); + test("calls client.files.download with resolved path", async () => { + const response = { contents: streamFromString("data") }; + mockFilesApi.download.mockResolvedValue(response); - const result = await client.download("file.txt"); + const result = await client.download("file.txt"); - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/file.txt", + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/file.txt", + }); + expect(result).toBe(response); }); - expect(result).toBe(response); - }); - test("passes absolute path directly", async () => { - const response = { contents: null }; - mockFilesApi.download.mockResolvedValue(response); + test("passes absolute path directly", async () => { + const response = { contents: null }; + mockFilesApi.download.mockResolvedValue(response); - await client.download("/Volumes/other/file.txt"); + await client.download("/Volumes/other/file.txt"); - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/other/file.txt", + expect(mockFilesApi.download).toHaveBeenCalledWith({ + file_path: "/Volumes/other/file.txt", + }); }); }); -}); -// ========================================================================= -// FilesClient – exists() -// ========================================================================= -describe("FilesClient – exists()", () => { - let client: FilesClient; + describe("exists()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("returns true when metadata succeeds", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 100, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); + test("returns true when metadata succeeds", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 100, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); - const result = await client.exists("/file.txt"); + const result = await client.exists("/file.txt"); - expect(result).toBe(true); - }); + expect(result).toBe(true); + }); - test("returns false on 404 ApiError", async () => { - mockFilesApi.getMetadata.mockRejectedValue( - new MockApiError("Not found", 404), - ); + test("returns false on 404 ApiError", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Not found", 404), + ); - const result = await client.exists("/missing.txt"); + const result = await client.exists("/missing.txt"); - expect(result).toBe(false); - }); + expect(result).toBe(false); + }); - test("rethrows non-404 ApiError", async () => { - mockFilesApi.getMetadata.mockRejectedValue( - new MockApiError("Server error", 500), - ); + test("rethrows non-404 ApiError", async () => { + mockFilesApi.getMetadata.mockRejectedValue( + new MockApiError("Server error", 500), + ); - await expect(client.exists("/file.txt")).rejects.toThrow("Server error"); - }); + await expect(client.exists("/file.txt")).rejects.toThrow("Server error"); + }); - test("rethrows generic errors", async () => { - mockFilesApi.getMetadata.mockRejectedValue(new Error("Network failure")); + test("rethrows generic errors", async () => { + mockFilesApi.getMetadata.mockRejectedValue(new Error("Network failure")); - await expect(client.exists("/file.txt")).rejects.toThrow("Network failure"); + await expect(client.exists("/file.txt")).rejects.toThrow( + "Network failure", + ); + }); }); -}); -// ========================================================================= -// FilesClient – metadata() -// ========================================================================= -describe("FilesClient – metadata()", () => { - let client: FilesClient; + describe("metadata()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("maps SDK response to FileMetadata", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 1234, - "content-type": "application/json", - "last-modified": "2025-06-15T10:00:00Z", - }); + test("maps SDK response to FileMetadata", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 1234, + "content-type": "application/json", + "last-modified": "2025-06-15T10:00:00Z", + }); - const result = await client.metadata("/data.json"); + const result = await client.metadata("/data.json"); - expect(result).toEqual({ - contentLength: 1234, - contentType: "application/json", - lastModified: "2025-06-15T10:00:00Z", + expect(result).toEqual({ + contentLength: 1234, + contentType: "application/json", + lastModified: "2025-06-15T10:00:00Z", + }); }); - }); - test("uses contentTypeFromPath to resolve octet-stream", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 500, - "content-type": "application/octet-stream", - "last-modified": "2025-01-01", + test("uses contentTypeFromPath to resolve octet-stream", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 500, + "content-type": "application/octet-stream", + "last-modified": "2025-01-01", + }); + + const result = await client.metadata("/image.png"); + + expect(result.contentType).toBe("image/png"); }); - const result = await client.metadata("/image.png"); + test("handles undefined content-type from SDK", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 100, + "content-type": undefined, + "last-modified": "2025-01-01", + }); - expect(result.contentType).toBe("image/png"); - }); + const result = await client.metadata("/data.csv"); - test("handles undefined content-type from SDK", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 100, - "content-type": undefined, - "last-modified": "2025-01-01", + expect(result.contentType).toBe("text/csv"); }); - const result = await client.metadata("/data.csv"); + test("resolves relative path via defaultVolume", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 0, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); - expect(result.contentType).toBe("text/csv"); - }); + await client.metadata("notes.txt"); - test("resolves relative path via defaultVolume", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 0, - "content-type": "text/plain", - "last-modified": "2025-01-01", + expect(mockFilesApi.getMetadata).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/notes.txt", + }); }); + }); - await client.metadata("notes.txt"); + describe("upload()", () => { + let client: FilesClient; + let fetchSpy: ReturnType; - expect(mockFilesApi.getMetadata).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/notes.txt", + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); + mockConfig.authenticate.mockResolvedValue(undefined); + fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); }); - }); -}); -// ========================================================================= -// FilesClient – upload() -// ========================================================================= -describe("FilesClient – upload()", () => { - let client: FilesClient; - let fetchSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, - }); - mockClient.config.authenticate.mockResolvedValue(undefined); - fetchSpy = vi.fn().mockResolvedValue({ ok: true }); - vi.stubGlobal("fetch", fetchSpy); - }); + afterEach(() => { + vi.unstubAllGlobals(); + }); - afterEach(() => { - vi.unstubAllGlobals(); - }); + test("handles string input", async () => { + await client.upload("file.txt", "hello world"); - test("handles string input", async () => { - await client.upload("file.txt", "hello world"); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.stringContaining( - "/api/2.0/fs/files/Volumes/catalog/schema/vol/file.txt", - ), - expect.objectContaining({ - method: "PUT", - body: "hello world", - }), - ); - }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining( + "/api/2.0/fs/files/Volumes/catalog/schema/vol/file.txt", + ), + expect.objectContaining({ + method: "PUT", + body: "hello world", + }), + ); + }); - test("handles Buffer input", async () => { - const buf = Buffer.from("buffer data"); - await client.upload("file.bin", buf); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: "PUT", - body: buf, - }), - ); - }); + test("handles Buffer input", async () => { + const buf = Buffer.from("buffer data"); + await client.upload("file.bin", buf); - test("handles ReadableStream input (streams directly)", async () => { - const stream = streamFromString("stream data"); - await client.upload("file.txt", stream); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: "PUT", - body: expect.any(ReadableStream), - duplex: "half", - }), - ); - }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "PUT", + body: buf, + }), + ); + }); - test("defaults overwrite to true", async () => { - await client.upload("file.txt", "data"); + test("handles ReadableStream input (streams directly)", async () => { + const stream = streamFromString("stream data"); + await client.upload("file.txt", stream); - const url = fetchSpy.mock.calls[0][0] as string; - expect(url).toContain("overwrite=true"); - }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: "PUT", + body: expect.any(ReadableStream), + duplex: "half", + }), + ); + }); - test("sets overwrite=false when specified", async () => { - await client.upload("file.txt", "data", { overwrite: false }); + test("defaults overwrite to true", async () => { + await client.upload("file.txt", "data"); - const url = fetchSpy.mock.calls[0][0] as string; - expect(url).toContain("overwrite=false"); - }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("overwrite=true"); + }); - test("calls config.authenticate on the headers", async () => { - await client.upload("file.txt", "data"); + test("sets overwrite=false when specified", async () => { + await client.upload("file.txt", "data", { overwrite: false }); - expect(mockClient.config.authenticate).toHaveBeenCalledWith( - expect.any(Headers), - ); - }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("overwrite=false"); + }); - test("builds URL from client.config.host", async () => { - await client.upload("file.txt", "data"); + test("calls config.authenticate on the headers", async () => { + await client.upload("file.txt", "data"); - const url = fetchSpy.mock.calls[0][0] as string; - expect(url).toMatch( - /^https:\/\/test\.databricks\.com\/api\/2\.0\/fs\/files/, - ); - }); + expect(mockConfig.authenticate).toHaveBeenCalledWith(expect.any(Headers)); + }); + + test("builds URL from client.config.host", async () => { + await client.upload("file.txt", "data"); - test("throws on non-ok response", async () => { - fetchSpy.mockResolvedValue({ - ok: false, - status: 403, - text: () => Promise.resolve("Forbidden"), + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch( + /^https:\/\/test\.databricks\.com\/api\/2\.0\/fs\/files/, + ); }); - await expect(client.upload("file.txt", "data")).rejects.toThrow( - "Upload failed (403): Forbidden", - ); - }); + test("throws on non-ok response", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve("Forbidden"), + }); + + await expect(client.upload("file.txt", "data")).rejects.toThrow( + "Upload failed (403): Forbidden", + ); + }); - test("resolves absolute paths directly", async () => { - await client.upload("/Volumes/other/vol/file.txt", "data"); + test("resolves absolute paths directly", async () => { + await client.upload("/Volumes/other/vol/file.txt", "data"); - const url = fetchSpy.mock.calls[0][0] as string; - expect(url).toContain("/api/2.0/fs/files/Volumes/other/vol/file.txt"); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain("/api/2.0/fs/files/Volumes/other/vol/file.txt"); + }); }); -}); -// ========================================================================= -// FilesClient – createDirectory() -// ========================================================================= -describe("FilesClient – createDirectory()", () => { - let client: FilesClient; + describe("createDirectory()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("calls client.files.createDirectory with resolved path", async () => { - mockFilesApi.createDirectory.mockResolvedValue(undefined); + test("calls client.files.createDirectory with resolved path", async () => { + mockFilesApi.createDirectory.mockResolvedValue(undefined); - await client.createDirectory("new-dir"); + await client.createDirectory("new-dir"); - expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol/new-dir", + expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ + directory_path: "/Volumes/catalog/schema/vol/new-dir", + }); }); - }); - test("uses absolute path when provided", async () => { - mockFilesApi.createDirectory.mockResolvedValue(undefined); + test("uses absolute path when provided", async () => { + mockFilesApi.createDirectory.mockResolvedValue(undefined); - await client.createDirectory("/Volumes/other/path/new-dir"); + await client.createDirectory("/Volumes/other/path/new-dir"); - expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ - directory_path: "/Volumes/other/path/new-dir", + expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ + directory_path: "/Volumes/other/path/new-dir", + }); }); }); -}); -// ========================================================================= -// FilesClient – delete() -// ========================================================================= -describe("FilesClient – delete()", () => { - let client: FilesClient; + describe("delete()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("calls client.files.delete with resolved path", async () => { - mockFilesApi.delete.mockResolvedValue(undefined); + test("calls client.files.delete with resolved path", async () => { + mockFilesApi.delete.mockResolvedValue(undefined); - await client.delete("file.txt"); + await client.delete("file.txt"); - expect(mockFilesApi.delete).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/file.txt", + expect(mockFilesApi.delete).toHaveBeenCalledWith({ + file_path: "/Volumes/catalog/schema/vol/file.txt", + }); }); - }); - test("uses absolute path when provided", async () => { - mockFilesApi.delete.mockResolvedValue(undefined); + test("uses absolute path when provided", async () => { + mockFilesApi.delete.mockResolvedValue(undefined); - await client.delete("/Volumes/other/file.txt"); + await client.delete("/Volumes/other/file.txt"); - expect(mockFilesApi.delete).toHaveBeenCalledWith({ - file_path: "/Volumes/other/file.txt", + expect(mockFilesApi.delete).toHaveBeenCalledWith({ + file_path: "/Volumes/other/file.txt", + }); }); }); -}); -// ========================================================================= -// FilesClient – preview() -// ========================================================================= -describe("FilesClient – preview()", () => { - let client: FilesClient; + describe("preview()", () => { + let client: FilesClient; - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, + beforeEach(() => { + vi.clearAllMocks(); + client = new FilesClient({ + defaultVolume: "/Volumes/catalog/schema/vol", + client: mockClient as any, + }); }); - }); - test("text files return truncated preview (max 1024 bytes)", async () => { - const longText = "A".repeat(2000); + test("text files return truncated preview (max 1024 bytes)", async () => { + const longText = "A".repeat(2000); - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 2000, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(longText), - }); + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 2000, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(longText), + }); - const result = await client.preview("/file.txt"); - - expect(result.isText).toBe(true); - expect(result.isImage).toBe(false); - expect(result.textPreview).not.toBeNull(); - expect(result.textPreview!.length).toBeLessThanOrEqual(1024); - }); + const result = await client.preview("/file.txt"); - test("text/html files are treated as text", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 30, - "content-type": "text/html", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString("

Hello

"), + expect(result.isText).toBe(true); + expect(result.isImage).toBe(false); + expect(result.textPreview).not.toBeNull(); + expect(result.textPreview!.length).toBeLessThanOrEqual(1024); }); - const result = await client.preview("/page.html"); + test("text/html files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 30, + "content-type": "text/html", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("

Hello

"), + }); - expect(result.isText).toBe(true); - expect(result.textPreview).toBe("

Hello

"); - }); + const result = await client.preview("/page.html"); - test("application/json files are treated as text", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 20, - "content-type": "application/json", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString('{"key":"value"}'), + expect(result.isText).toBe(true); + expect(result.textPreview).toBe("

Hello

"); }); - const result = await client.preview("/data.json"); + test("application/json files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 20, + "content-type": "application/json", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString('{"key":"value"}'), + }); - expect(result.isText).toBe(true); - expect(result.textPreview).toBe('{"key":"value"}'); - }); + const result = await client.preview("/data.json"); - test("application/xml files are treated as text", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 30, - "content-type": "application/xml", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(""), + expect(result.isText).toBe(true); + expect(result.textPreview).toBe('{"key":"value"}'); }); - const result = await client.preview("/data.xml"); + test("application/xml files are treated as text", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 30, + "content-type": "application/xml", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(""), + }); - expect(result.isText).toBe(true); - expect(result.textPreview).toBe(""); - }); + const result = await client.preview("/data.xml"); - test("image files return isImage: true, textPreview: null", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 5000, - "content-type": "image/png", - "last-modified": "2025-01-01", + expect(result.isText).toBe(true); + expect(result.textPreview).toBe(""); }); - const result = await client.preview("/image.png"); + test("image files return isImage: true, textPreview: null", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 5000, + "content-type": "image/png", + "last-modified": "2025-01-01", + }); - expect(result.isImage).toBe(true); - expect(result.isText).toBe(false); - expect(result.textPreview).toBeNull(); - }); + const result = await client.preview("/image.png"); - test("other files return isText: false, isImage: false, textPreview: null", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 1000, - "content-type": "application/pdf", - "last-modified": "2025-01-01", + expect(result.isImage).toBe(true); + expect(result.isText).toBe(false); + expect(result.textPreview).toBeNull(); }); - const result = await client.preview("/doc.pdf"); + test("other files return isText: false, isImage: false, textPreview: null", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 1000, + "content-type": "application/pdf", + "last-modified": "2025-01-01", + }); - expect(result.isText).toBe(false); - expect(result.isImage).toBe(false); - expect(result.textPreview).toBeNull(); - }); + const result = await client.preview("/doc.pdf"); - test("empty file contents return empty string preview", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 0, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: null, + expect(result.isText).toBe(false); + expect(result.isImage).toBe(false); + expect(result.textPreview).toBeNull(); }); - const result = await client.preview("/empty.txt"); + test("empty file contents return empty string preview", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 0, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: null, + }); - expect(result.isText).toBe(true); - expect(result.isImage).toBe(false); - expect(result.textPreview).toBe(""); - }); + const result = await client.preview("/empty.txt"); - test("preview spreads metadata into result", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 42, - "content-type": "text/plain", - "last-modified": "2025-06-15T10:00:00Z", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString("hello"), + expect(result.isText).toBe(true); + expect(result.isImage).toBe(false); + expect(result.textPreview).toBe(""); }); - const result = await client.preview("/notes.txt"); + test("preview spreads metadata into result", async () => { + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": 42, + "content-type": "text/plain", + "last-modified": "2025-06-15T10:00:00Z", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("hello"), + }); - expect(result.contentLength).toBe(42); - expect(result.contentType).toBe("text/plain"); - expect(result.lastModified).toBe("2025-06-15T10:00:00Z"); - expect(result.textPreview).toBe("hello"); - }); + const result = await client.preview("/notes.txt"); - test("short text file returns full content", async () => { - const content = "Short file."; - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": content.length, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(content), + expect(result.contentLength).toBe(42); + expect(result.contentType).toBe("text/plain"); + expect(result.lastModified).toBe("2025-06-15T10:00:00Z"); + expect(result.textPreview).toBe("hello"); }); - const result = await client.preview("/short.txt"); + test("short text file returns full content", async () => { + const content = "Short file."; + mockFilesApi.getMetadata.mockResolvedValue({ + "content-length": content.length, + "content-type": "text/plain", + "last-modified": "2025-01-01", + }); + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(content), + }); + + const result = await client.preview("/short.txt"); - expect(result.textPreview).toBe(content); + expect(result.textPreview).toBe(content); + }); }); }); diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index 979f84f5..1ff000de 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -1,13 +1,10 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../../context/service-context"; -import { contentTypeFromPath } from "../helpers"; import { FilesClient } from "../lib"; import { FilesPlugin, files } from "../plugin"; +import { streamFromString } from "./utils"; -// --------------------------------------------------------------------------- -// Mock SDK + CacheManager -// --------------------------------------------------------------------------- const { mockFilesApi, mockClient, MockApiError, mockCacheInstance } = vi.hoisted(() => { const mockFilesApi = { @@ -64,493 +61,6 @@ vi.mock("../../../cache", () => ({ }, })); -// --------------------------------------------------------------------------- -// Helper: create a ReadableStream from a string -// --------------------------------------------------------------------------- -function streamFromString(text: string): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(text)); - controller.close(); - }, - }); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("contentTypeFromPath", () => { - test("returns reported content-type when not application/octet-stream", () => { - expect(contentTypeFromPath("/file.txt", "text/html")).toBe("text/html"); - }); - - test("falls back to extension lookup when reported is application/octet-stream", () => { - expect(contentTypeFromPath("/image.png", "application/octet-stream")).toBe( - "image/png", - ); - }); - - test("falls back to extension lookup when no reported type", () => { - expect(contentTypeFromPath("/data.json")).toBe("application/json"); - }); - - test("returns application/octet-stream for unknown extensions with no reported type", () => { - expect(contentTypeFromPath("/file.xyz")).toBe("application/octet-stream"); - }); - - test("handles case-insensitive extensions", () => { - expect(contentTypeFromPath("/image.PNG")).toBe("image/png"); - expect(contentTypeFromPath("/data.Json")).toBe("application/json"); - }); -}); - -describe("FilesClient - Path Resolution", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test("absolute paths are returned as-is", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, - }); - - // Exercise resolvePath indirectly via download - mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("/Volumes/other/path/file.txt"); - - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/other/path/file.txt", - }); - }); - - test("relative paths prepend defaultVolume", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, - }); - - mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("subdir/file.txt"); - - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/subdir/file.txt", - }); - }); - - test("relative path without defaultVolume throws error", () => { - const client = new FilesClient({ client: mockClient as any }); - - expect(() => client.download("file.txt")).rejects.toThrow( - "Cannot resolve relative path", - ); - }); - - test("volume() creates new client scoped to a different volume", () => { - const client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol1", - client: mockClient as any, - }); - - const scoped = client.volume("/Volumes/catalog/schema/vol2"); - - mockFilesApi.download.mockResolvedValue({ contents: null }); - scoped.download("file.txt"); - - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol2/file.txt", - }); - }); -}); - -describe("FilesClient - Core Operations", () => { - let client: FilesClient; - - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, - }); - }); - - describe("list()", () => { - test("collects async iterator entries", async () => { - const entries = [ - { - name: "file1.txt", - path: "/Volumes/catalog/schema/vol/file1.txt", - is_directory: false, - }, - { - name: "subdir", - path: "/Volumes/catalog/schema/vol/subdir", - is_directory: true, - }, - ]; - - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () { - for (const entry of entries) { - yield entry; - } - })(), - ); - - const result = await client.list(); - - expect(result).toEqual(entries); - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol", - }); - }); - - test("uses defaultVolume when no path provided", async () => { - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () {})(), - ); - - await client.list(); - - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol", - }); - }); - - test("throws when no path and no defaultVolume", async () => { - const noVolumeClient = new FilesClient({ client: mockClient as any }); - - await expect(noVolumeClient.list()).rejects.toThrow( - "No directory path provided and no default volume set.", - ); - }); - - test("uses provided path when given", async () => { - mockFilesApi.listDirectoryContents.mockReturnValue( - (async function* () {})(), - ); - - await client.list("/Volumes/other/path"); - - expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ - directory_path: "/Volumes/other/path", - }); - }); - }); - - describe("read()", () => { - test("decodes ReadableStream to UTF-8 string", async () => { - const content = "Hello, world!"; - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(content), - }); - - const result = await client.read("/file.txt"); - - expect(result).toBe(content); - }); - - test("returns empty string for no contents", async () => { - mockFilesApi.download.mockResolvedValue({ contents: null }); - - const result = await client.read("/empty.txt"); - - expect(result).toBe(""); - }); - }); - - describe("download()", () => { - test("calls client.files.download with resolved path", async () => { - const response = { contents: streamFromString("data") }; - mockFilesApi.download.mockResolvedValue(response); - - const result = await client.download("file.txt"); - - expect(mockFilesApi.download).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/file.txt", - }); - expect(result).toBe(response); - }); - }); - - describe("exists()", () => { - test("returns true when metadata succeeds", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 100, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); - - const result = await client.exists("/file.txt"); - - expect(result).toBe(true); - }); - - test("returns false on 404 ApiError", async () => { - mockFilesApi.getMetadata.mockRejectedValue( - new MockApiError("Not found", 404), - ); - - const result = await client.exists("/missing.txt"); - - expect(result).toBe(false); - }); - - test("rethrows other errors", async () => { - mockFilesApi.getMetadata.mockRejectedValue( - new MockApiError("Server error", 500), - ); - - await expect(client.exists("/file.txt")).rejects.toThrow("Server error"); - }); - }); - - describe("metadata()", () => { - test("maps SDK response to FileMetadata", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 1234, - "content-type": "application/json", - "last-modified": "2025-06-15T10:00:00Z", - }); - - const result = await client.metadata("/data.json"); - - expect(result).toEqual({ - contentLength: 1234, - contentType: "application/json", - lastModified: "2025-06-15T10:00:00Z", - }); - }); - - test("uses contentTypeFromPath for content-type", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 500, - "content-type": "application/octet-stream", - "last-modified": "2025-01-01", - }); - - const result = await client.metadata("/image.png"); - - expect(result.contentType).toBe("image/png"); - }); - }); - - describe("upload()", () => { - let fetchSpy: ReturnType; - - beforeEach(() => { - mockClient.config.authenticate.mockResolvedValue(undefined); - fetchSpy = vi.fn().mockResolvedValue({ ok: true }); - vi.stubGlobal("fetch", fetchSpy); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - test("handles string input", async () => { - await client.upload("file.txt", "hello world"); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.stringContaining( - "/api/2.0/fs/files/Volumes/catalog/schema/vol/file.txt", - ), - expect.objectContaining({ - method: "PUT", - body: "hello world", - }), - ); - }); - - test("handles Buffer input", async () => { - const buf = Buffer.from("buffer data"); - await client.upload("file.bin", buf); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: "PUT", - body: buf, - }), - ); - }); - - test("handles ReadableStream input", async () => { - const stream = streamFromString("stream data"); - await client.upload("file.txt", stream); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: "PUT", - body: expect.any(ReadableStream), - duplex: "half", - }), - ); - }); - - test("sets overwrite param", async () => { - await client.upload("file.txt", "data", { overwrite: false }); - - const url = fetchSpy.mock.calls[0][0] as string; - expect(url).toContain("overwrite=false"); - }); - - test("defaults overwrite to true", async () => { - await client.upload("file.txt", "data"); - - const url = fetchSpy.mock.calls[0][0] as string; - expect(url).toContain("overwrite=true"); - }); - - test("throws on non-ok response", async () => { - fetchSpy.mockResolvedValue({ - ok: false, - status: 403, - text: () => Promise.resolve("Forbidden"), - }); - - await expect(client.upload("file.txt", "data")).rejects.toThrow( - "Upload failed (403): Forbidden", - ); - }); - }); - - describe("createDirectory()", () => { - test("calls client.files.createDirectory", async () => { - mockFilesApi.createDirectory.mockResolvedValue(undefined); - - await client.createDirectory("new-dir"); - - expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ - directory_path: "/Volumes/catalog/schema/vol/new-dir", - }); - }); - }); - - describe("delete()", () => { - test("calls client.files.delete", async () => { - mockFilesApi.delete.mockResolvedValue(undefined); - - await client.delete("file.txt"); - - expect(mockFilesApi.delete).toHaveBeenCalledWith({ - file_path: "/Volumes/catalog/schema/vol/file.txt", - }); - }); - }); -}); - -describe("FilesClient - Preview", () => { - let client: FilesClient; - - beforeEach(() => { - vi.clearAllMocks(); - client = new FilesClient({ - defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, - }); - }); - - test("text files return truncated preview (max 1024 bytes)", async () => { - const longText = "A".repeat(2000); - - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 2000, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(longText), - }); - - const result = await client.preview("/file.txt"); - - expect(result.isText).toBe(true); - expect(result.isImage).toBe(false); - expect(result.textPreview).not.toBeNull(); - expect(result.textPreview!.length).toBeLessThanOrEqual(1024); - }); - - test("application/json files are treated as text", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 20, - "content-type": "application/json", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString('{"key":"value"}'), - }); - - const result = await client.preview("/data.json"); - - expect(result.isText).toBe(true); - expect(result.textPreview).toBe('{"key":"value"}'); - }); - - test("application/xml files are treated as text", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 30, - "content-type": "application/xml", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: streamFromString(""), - }); - - const result = await client.preview("/data.xml"); - - expect(result.isText).toBe(true); - expect(result.textPreview).toBe(""); - }); - - test("image files return isImage: true, textPreview: null", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 5000, - "content-type": "image/png", - "last-modified": "2025-01-01", - }); - - const result = await client.preview("/image.png"); - - expect(result.isImage).toBe(true); - expect(result.isText).toBe(false); - expect(result.textPreview).toBeNull(); - }); - - test("other files return isText: false, isImage: false, textPreview: null", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 1000, - "content-type": "application/pdf", - "last-modified": "2025-01-01", - }); - - const result = await client.preview("/doc.pdf"); - - expect(result.isText).toBe(false); - expect(result.isImage).toBe(false); - expect(result.textPreview).toBeNull(); - }); - - test("empty file contents return empty string preview", async () => { - mockFilesApi.getMetadata.mockResolvedValue({ - "content-length": 0, - "content-type": "text/plain", - "last-modified": "2025-01-01", - }); - mockFilesApi.download.mockResolvedValue({ - contents: null, - }); - - const result = await client.preview("/empty.txt"); - - expect(result.isText).toBe(true); - expect(result.textPreview).toBe(""); - }); -}); - describe("FilesPlugin", () => { let serviceContextMock: Awaited>; @@ -589,7 +99,6 @@ describe("FilesPlugin", () => { expect(exported).toHaveProperty("delete"); expect(exported).toHaveProperty("preview"); - // All exports should be functions for (const value of Object.values(exported)) { expect(typeof value).toBe("function"); } @@ -607,9 +116,11 @@ describe("FilesPlugin", () => { plugin.injectRoutes(mockRouter); - // 8 GET routes: root, list, read, download, raw, exists, metadata, preview + // 8 GET routes + // root, list, read, download, raw, exists, metadata, preview expect(mockRouter.get).toHaveBeenCalledTimes(8); - // 3 POST routes: upload, mkdir, delete + // 3 POST routes: + // upload, mkdir, delete expect(mockRouter.post).toHaveBeenCalledTimes(3); expect(mockRouter.put).not.toHaveBeenCalled(); expect(mockRouter.patch).not.toHaveBeenCalled(); diff --git a/packages/appkit/src/plugins/files/tests/utils.ts b/packages/appkit/src/plugins/files/tests/utils.ts new file mode 100644 index 00000000..d204d8da --- /dev/null +++ b/packages/appkit/src/plugins/files/tests/utils.ts @@ -0,0 +1,22 @@ +export function streamFromString(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +// Creates a ReadableStream that yields multiple chunks +export function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); +} From 5b0a6829cd1ae157057d5c82b34334850759cebb Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 16:59:15 +0100 Subject: [PATCH 07/23] chore: skip bail `req.body` type for specific routes in Server plugin --- packages/appkit/src/plugin/plugin.ts | 15 +++++- .../appkit/src/plugin/tests/plugin.test.ts | 44 ++++++++++++++++++ packages/appkit/src/plugins/files/plugin.ts | 1 + packages/appkit/src/plugins/server/index.ts | 21 +++++++-- .../src/plugins/server/tests/server.test.ts | 46 +++++++++++++++++++ packages/shared/src/plugin.ts | 4 ++ 6 files changed, 126 insertions(+), 5 deletions(-) diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 4f60f195..374e2f77 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -51,6 +51,7 @@ const EXCLUDED_FROM_PROXY = new Set([ "shutdown", "injectRoutes", "getEndpoints", + "getSkipBodyParsingPaths", "abortActiveOperations", // asUser itself - prevent chaining like .asUser().asUser() "asUser", @@ -157,6 +158,9 @@ export abstract class Plugin< /** Registered endpoints for this plugin */ private registeredEndpoints: PluginEndpointMap = {}; + /** Paths that opt out of JSON body parsing (e.g. file upload routes) */ + private skipBodyParsingPaths: Set = new Set(); + /** * Plugin initialization phase. * - 'core': Initialized first (e.g., config plugins) @@ -191,6 +195,10 @@ export abstract class Plugin< return this.registeredEndpoints; } + getSkipBodyParsingPaths(): ReadonlySet { + return this.skipBodyParsingPaths; + } + abortActiveOperations(): void { this.streamManager.abortAll(); } @@ -372,7 +380,12 @@ export abstract class Plugin< router[method](path, handler); - this.registerEndpoint(name, `/api/${this.name}${path}`); + const fullPath = `/api/${this.name}${path}`; + this.registerEndpoint(name, fullPath); + + if (config.skipBodyParsing) { + this.skipBodyParsingPaths.add(fullPath); + } } // build execution options by merging defaults, plugin config, and user overrides diff --git a/packages/appkit/src/plugin/tests/plugin.test.ts b/packages/appkit/src/plugin/tests/plugin.test.ts index 51f677a8..6c0732d8 100644 --- a/packages/appkit/src/plugin/tests/plugin.test.ts +++ b/packages/appkit/src/plugin/tests/plugin.test.ts @@ -552,6 +552,50 @@ describe("Plugin", () => { }); }); + describe("getSkipBodyParsingPaths", () => { + test("should return empty set by default", () => { + const plugin = new TestPlugin(config); + + expect(plugin.getSkipBodyParsingPaths().size).toBe(0); + }); + + test("should include paths from routes with skipBodyParsing: true", () => { + const plugin = new TestPlugin({ ...config, name: "test" }); + const mockRouter = { + post: vi.fn(), + } as any; + + (plugin as any).route(mockRouter, { + name: "upload", + method: "post", + path: "/upload", + skipBodyParsing: true, + handler: vi.fn(), + }); + + const paths = plugin.getSkipBodyParsingPaths(); + expect(paths.has("/api/test/upload")).toBe(true); + expect(paths.size).toBe(1); + }); + + test("should not include paths from routes without skipBodyParsing", () => { + const plugin = new TestPlugin({ ...config, name: "test" }); + const mockRouter = { + post: vi.fn(), + } as any; + + (plugin as any).route(mockRouter, { + name: "create", + method: "post", + path: "/create", + handler: vi.fn(), + }); + + const paths = plugin.getSkipBodyParsingPaths(); + expect(paths.size).toBe(0); + }); + }); + describe("static properties", () => { test("should have default phase of 'normal'", () => { expect(Plugin.phase).toBe("normal"); diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 9d5a1583..2909090a 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -161,6 +161,7 @@ export class FilesPlugin extends Plugin { name: "upload", method: "post", path: "/upload", + skipBodyParsing: true, handler: async (req: express.Request, res: express.Response) => { await this._handleUpload(req, res); }, diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 407a4c58..a2fb3354 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -50,6 +50,7 @@ export class ServerPlugin extends Plugin { private remoteTunnelController?: RemoteTunnelController; protected declare config: ServerConfig; private serverExtensions: ((app: express.Application) => void)[] = []; + private rawBodyPaths: Set = new Set(); static phase: PluginPhase = "deferred"; constructor(config: ServerConfig) { @@ -95,10 +96,12 @@ export class ServerPlugin extends Plugin { this.serverApplication.use( express.json({ type: (req) => { - // Skip JSON parsing for file upload routes so raw body - // data flows through to the handler (express.json default - // limit is 100KB which silently drops larger payloads). - if (req.url?.includes("/upload")) return false; + // Skip JSON parsing for routes that declared skipBodyParsing + // (e.g. file uploads where the raw body must flow through). + // rawBodyPaths is populated by extendRoutes() below; the type + // callback runs per-request so the set is already filled. + const urlPath = req.url?.split("?")[0]; + if (urlPath && this.rawBodyPaths.has(urlPath)) return false; const ct = req.headers["content-type"] ?? ""; return ct.includes("json"); }, @@ -205,6 +208,16 @@ export class ServerPlugin extends Plugin { // Collect named endpoints from the plugin endpoints[plugin.name] = plugin.getEndpoints(); + + // Collect paths that should skip body parsing + if ( + plugin.getSkipBodyParsingPaths && + typeof plugin.getSkipBodyParsingPaths === "function" + ) { + for (const p of plugin.getSkipBodyParsingPaths()) { + this.rawBodyPaths.add(p); + } + } } } diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts index 31305fc7..099f2b13 100644 --- a/packages/appkit/src/plugins/server/tests/server.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.test.ts @@ -284,6 +284,52 @@ describe("ServerPlugin", () => { ); }); + test("should skip body parsing for paths declared by plugins", async () => { + process.env.NODE_ENV = "production"; + + const plugins: any = { + files: { + name: "files", + injectRoutes: vi.fn(), + getEndpoints: vi.fn().mockReturnValue({}), + getSkipBodyParsingPaths: vi + .fn() + .mockReturnValue(new Set(["/api/files/upload"])), + }, + }; + + const plugin = new ServerPlugin({ autoStart: false, plugins }); + await plugin.start(); + + // Get the type function passed to express.json + const jsonCall = vi.mocked(express.json).mock.calls[0][0] as any; + const typeFn = jsonCall.type; + + // Should skip body parsing for the declared path + expect(typeFn({ url: "/api/files/upload", headers: {} })).toBe(false); + + // Should skip body parsing for declared path with query string + expect(typeFn({ url: "/api/files/upload?path=foo", headers: {} })).toBe( + false, + ); + + // Should NOT skip body parsing for other routes (no hardcoded /upload check) + expect( + typeFn({ + url: "/api/other/upload", + headers: { "content-type": "application/json" }, + }), + ).toBe(true); + + // Should still parse JSON for normal routes + expect( + typeFn({ + url: "/api/analytics/query", + headers: { "content-type": "application/json" }, + }), + ).toBe(true); + }); + test("extendRoutes registers plugin routes correctly", async () => { process.env.NODE_ENV = "production"; diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 54d8f583..6de7b06d 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -13,6 +13,8 @@ export interface BasePlugin { getEndpoints(): PluginEndpointMap; + getSkipBodyParsingPaths?(): ReadonlySet; + exports?(): unknown; } @@ -203,6 +205,8 @@ export type RouteConfig = { method: HttpMethod; path: string; handler: (req: IAppRequest, res: IAppResponse) => Promise; + /** When true, the server will skip JSON body parsing for this route (e.g. file uploads). */ + skipBodyParsing?: boolean; }; /** Map of endpoint names to their full paths for a plugin */ From a1c05c4e0a8faf9ad3725bf2da764ba82f1768de Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 17:07:23 +0100 Subject: [PATCH 08/23] chore: remove dev-playground files integration Move dev-playground route and UI to a separate branch (plugin/files-playground). Co-Authored-By: Claude Opus 4.6 Signed-off-by: Atila Fassina --- .../client/src/appKitTypes.d.ts | 71 +- .../client/src/routeTree.gen.ts | 33 + .../client/src/routes/__root.tsx | 8 - .../client/src/routes/files.route.tsx | 617 ------------------ .../client/src/routes/index.tsx | 18 - 5 files changed, 95 insertions(+), 652 deletions(-) delete mode 100644 apps/dev-playground/client/src/routes/files.route.tsx diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index 049fae9f..0e0ae0b0 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -14,28 +14,46 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + app_name: string; + /** @sqlType STRING */ + day_of_week: string; + /** @sqlType DECIMAL(35,2) */ + spend: number; }>; }; apps_list: { name: "apps_list"; parameters: Record; result: Array<{ - ; + /** @sqlType STRING */ + id: string; + /** @sqlType STRING */ + name: string; + /** @sqlType STRING */ + creator: string; + /** @sqlType STRING */ + tags: string; + /** @sqlType DECIMAL(38,6) */ + totalSpend: number; + /** @sqlType DATE */ + createdAt: string; }>; }; cost_recommendations: { name: "cost_recommendations"; parameters: Record; result: Array<{ - ; + /** @sqlType INT */ + dummy: number; }>; }; example: { name: "example"; parameters: Record; result: Array<{ - ; + /** @sqlType BOOLEAN */ + "(1 = 1)": boolean; }>; }; spend_data: { @@ -55,7 +73,12 @@ declare module "@databricks/appkit-ui/react" { creator: SQLStringMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + group_key: string; + /** @sqlType TIMESTAMP */ + aggregation_period: string; + /** @sqlType DECIMAL(38,6) */ + cost_usd: number; }>; }; spend_summary: { @@ -69,7 +92,12 @@ declare module "@databricks/appkit-ui/react" { startDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType DECIMAL(33,0) */ + total: number; + /** @sqlType DECIMAL(33,0) */ + average: number; + /** @sqlType DECIMAL(33,0) */ + forecasted: number; }>; }; sql_helpers_test: { @@ -89,7 +117,22 @@ declare module "@databricks/appkit-ui/react" { binaryParam: SQLStringMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + string_value: string; + /** @sqlType STRING */ + number_value: string; + /** @sqlType STRING */ + boolean_value: string; + /** @sqlType STRING */ + date_value: string; + /** @sqlType STRING */ + timestamp_value: string; + /** @sqlType BINARY */ + binary_value: string; + /** @sqlType STRING */ + binary_hex: string; + /** @sqlType INT */ + binary_length: number; }>; }; top_contributors: { @@ -103,7 +146,10 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + app_name: string; + /** @sqlType DECIMAL(38,6) */ + total_cost_usd: number; }>; }; untagged_apps: { @@ -117,7 +163,14 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + app_name: string; + /** @sqlType STRING */ + creator: string; + /** @sqlType DECIMAL(38,6) */ + total_cost_usd: number; + /** @sqlType DECIMAL(38,10) */ + avg_period_cost_usd: number; }>; }; } diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 3478fcc8..71b4f0e5 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -13,8 +13,11 @@ import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' +<<<<<<< HEAD import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' import { Route as FilesRouteRouteImport } from './routes/files.route' +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) import { Route as DataVisualizationRouteRouteImport } from './routes/data-visualization.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' @@ -40,6 +43,7 @@ const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ path: '/reconnect', getParentRoute: () => rootRouteImport, } as any) +<<<<<<< HEAD const LakebaseRouteRoute = LakebaseRouteRouteImport.update({ id: '/lakebase', path: '/lakebase', @@ -50,6 +54,8 @@ const FilesRouteRoute = FilesRouteRouteImport.update({ path: '/files', getParentRoute: () => rootRouteImport, } as any) +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) const DataVisualizationRouteRoute = DataVisualizationRouteRouteImport.update({ id: '/data-visualization', path: '/data-visualization', @@ -76,8 +82,11 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute +<<<<<<< HEAD '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -88,8 +97,11 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute +<<<<<<< HEAD '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -101,8 +113,11 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute +<<<<<<< HEAD '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -115,8 +130,11 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' +<<<<<<< HEAD | '/files' | '/lakebase' +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -127,8 +145,11 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' +<<<<<<< HEAD | '/files' | '/lakebase' +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -139,8 +160,11 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' +<<<<<<< HEAD | '/files' | '/lakebase' +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -152,8 +176,11 @@ export interface RootRouteChildren { AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute +<<<<<<< HEAD FilesRouteRoute: typeof FilesRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) ReconnectRouteRoute: typeof ReconnectRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute @@ -190,6 +217,7 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReconnectRouteRouteImport parentRoute: typeof rootRouteImport } +<<<<<<< HEAD '/lakebase': { id: '/lakebase' path: '/lakebase' @@ -204,6 +232,8 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FilesRouteRouteImport parentRoute: typeof rootRouteImport } +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) '/data-visualization': { id: '/data-visualization' path: '/data-visualization' @@ -240,8 +270,11 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, +<<<<<<< HEAD FilesRouteRoute: FilesRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, +======= +>>>>>>> aa76021 (chore: remove dev-playground files integration) ReconnectRouteRoute: ReconnectRouteRoute, SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index ad35594f..842ed077 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -80,14 +80,6 @@ function RootComponent() { SQL Helpers - - -
diff --git a/apps/dev-playground/client/src/routes/files.route.tsx b/apps/dev-playground/client/src/routes/files.route.tsx deleted file mode 100644 index 67317eb4..00000000 --- a/apps/dev-playground/client/src/routes/files.route.tsx +++ /dev/null @@ -1,617 +0,0 @@ -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, - Button, - Card, - Skeleton, -} from "@databricks/appkit-ui/react"; -import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; -import { - AlertCircle, - ArrowLeft, - ChevronRight, - Download, - FileIcon, - FolderIcon, - FolderPlus, - Loader2, - Trash2, - Upload, - X, -} from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Header } from "@/components/layout/header"; - -export const Route = createFileRoute("/files")({ - component: FilesRoute, - search: { - middlewares: [retainSearchParams(true)], - }, -}); - -interface DirectoryEntry { - name?: string; - path?: string; - is_directory?: boolean; - file_size?: number; - last_modified?: string; -} - -interface FilePreview { - contentLength: number | undefined; - contentType: string | undefined; - lastModified: string | undefined; - textPreview: string | null; - isText: boolean; - isImage: boolean; -} - -function FilesRoute() { - const [volumeRoot, setVolumeRoot] = useState(""); - const [currentPath, setCurrentPath] = useState(""); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); - const [preview, setPreview] = useState(null); - const [previewLoading, setPreviewLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const [deleting, setDeleting] = useState(false); - const [creatingDir, setCreatingDir] = useState(false); - const [newDirName, setNewDirName] = useState(""); - const [showNewDirInput, setShowNewDirInput] = useState(false); - const fileInputRef = useRef(null); - const newDirInputRef = useRef(null); - - const normalize = (p: string) => p.replace(/\/+$/, ""); - const isAtRoot = - !currentPath || normalize(currentPath) === normalize(volumeRoot); - - const loadDirectory = useCallback(async (path?: string) => { - setLoading(true); - setError(null); - setSelectedFile(null); - setPreview(null); - - try { - const url = path - ? `/api/files/list?path=${encodeURIComponent(path)}` - : "/api/files/list"; - const response = await fetch(url); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error( - data.error ?? `HTTP ${response.status}: ${response.statusText}`, - ); - } - - const data: DirectoryEntry[] = await response.json(); - data.sort((a, b) => { - if (a.is_directory && !b.is_directory) return -1; - if (!a.is_directory && b.is_directory) return 1; - return (a.name ?? "").localeCompare(b.name ?? ""); - }); - setEntries(data); - setCurrentPath(path ?? ""); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setEntries([]); - } finally { - setLoading(false); - } - }, []); - - const loadPreview = useCallback(async (filePath: string) => { - setPreviewLoading(true); - setPreview(null); - - try { - const response = await fetch( - `/api/files/preview?path=${encodeURIComponent(filePath)}`, - ); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error ?? `HTTP ${response.status}`); - } - - const data = await response.json(); - setPreview(data); - } catch { - setPreview(null); - } finally { - setPreviewLoading(false); - } - }, []); - - useEffect(() => { - fetch("/api/files/root") - .then((res) => res.json()) - .then((data) => { - const root = data.root ?? ""; - setVolumeRoot(root); - if (root) { - loadDirectory(root); - } else { - loadDirectory(); - } - }) - .catch(() => loadDirectory()); - }, [loadDirectory]); - - const resolveEntryPath = (entry: DirectoryEntry) => { - if (entry.path?.startsWith("/")) return entry.path; - const name = entry.name ?? ""; - return currentPath ? `${currentPath}/${name}` : name; - }; - - const handleEntryClick = (entry: DirectoryEntry) => { - const entryPath = resolveEntryPath(entry); - if (entry.is_directory) { - loadDirectory(entryPath); - } else { - setSelectedFile(entryPath); - loadPreview(entryPath); - } - }; - - const navigateToParent = () => { - if (isAtRoot) return; - const segments = currentPath.split("/").filter(Boolean); - segments.pop(); - const parentPath = `/${segments.join("/")}`; - if ( - volumeRoot && - normalize(parentPath).length <= normalize(volumeRoot).length - ) { - loadDirectory(volumeRoot); - return; - } - loadDirectory(parentPath); - }; - - const navigateToBreadcrumb = (index: number) => { - const targetSegments = [ - ...rootSegments, - ...breadcrumbSegments.slice(0, index + 1), - ]; - const targetPath = `/${targetSegments.join("/")}`; - loadDirectory(targetPath); - }; - - const handleUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - setUploading(true); - try { - const uploadPath = currentPath - ? `${currentPath}/${file.name}` - : file.name; - const response = await fetch( - `/api/files/upload?path=${encodeURIComponent(uploadPath)}`, - { method: "POST", body: file }, - ); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error ?? `Upload failed (${response.status})`); - } - - await loadDirectory(currentPath || undefined); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setUploading(false); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } - }; - - const handleDelete = async () => { - if (!selectedFile) return; - - const fileName = selectedFile.split("/").pop(); - if (!window.confirm(`Delete "${fileName}"?`)) return; - - setDeleting(true); - try { - const response = await fetch( - `/api/files/delete?path=${encodeURIComponent(selectedFile)}`, - { method: "POST" }, - ); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error ?? `Delete failed (${response.status})`); - } - - setSelectedFile(null); - setPreview(null); - await loadDirectory(currentPath || undefined); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setDeleting(false); - } - }; - - const handleCreateDirectory = async () => { - const name = newDirName.trim(); - if (!name) return; - - setCreatingDir(true); - try { - const dirPath = currentPath ? `${currentPath}/${name}` : name; - const response = await fetch("/api/files/mkdir", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: dirPath }), - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error( - data.error ?? `Create directory failed (${response.status})`, - ); - } - - setShowNewDirInput(false); - setNewDirName(""); - await loadDirectory(currentPath || undefined); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setCreatingDir(false); - } - }; - - const rootSegments = normalize(volumeRoot).split("/").filter(Boolean); - const allSegments = normalize(currentPath).split("/").filter(Boolean); - const breadcrumbSegments = allSegments.slice(rootSegments.length); - - const formatFileSize = (bytes: number | undefined) => { - if (bytes === undefined || bytes === null) return "Unknown"; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }; - - return ( -
-
-
- -
- - - - {breadcrumbSegments.length > 0 ? ( - loadDirectory(volumeRoot || undefined)} - > - {rootSegments.at(-1) ?? "Root"} - - ) : ( - - {rootSegments.at(-1) ?? "Root"} - - )} - - {breadcrumbSegments.map((segment, index) => ( - - - - {index === breadcrumbSegments.length - 1 ? ( - {segment} - ) : ( - navigateToBreadcrumb(index)} - > - {segment} - - )} - - - ))} - - - -
- - - -
-
- -
-
- - {!isAtRoot && ( - - )} - - {showNewDirInput && ( -
- - setNewDirName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleCreateDirectory(); - if (e.key === "Escape") { - setShowNewDirInput(false); - setNewDirName(""); - } - }} - placeholder="Folder name" - className="flex-1 text-sm bg-background border rounded px-2 py-1 outline-none focus:ring-1 focus:ring-ring" - disabled={creatingDir} - /> - - -
- )} - - {loading && ( -
- - - -
- )} - - {error && ( -
- -

{error}

- -
- )} - - {!loading && !error && entries.length === 0 && ( -
- -

- {currentPath - ? "This directory is empty." - : "No default volume configured. Set DATABRICKS_DEFAULT_VOLUME to get started."} -

-
- )} - - {!loading && - !error && - entries.map((entry) => { - const entryPath = resolveEntryPath(entry); - const isSelected = selectedFile === entryPath; - - return ( - - ); - })} -
-
- - {/* Preview panel */} -
- - {!selectedFile && ( -
- -

Select a file to preview

-
- )} - - {selectedFile && previewLoading && ( -
- - - - -
- )} - - {selectedFile && !previewLoading && preview && ( -
-
-

- {selectedFile.split("/").pop()} -

-

- {selectedFile} -

-
- -
-
- Size - - {formatFileSize(preview.contentLength)} - -
-
- Type - - {preview.contentType ?? "Unknown"} - -
- {preview.lastModified && ( -
- Modified - - {preview.lastModified} - -
- )} -
- -
- - -
- - {preview.isImage && ( -
- {selectedFile.split("/").pop() -
- )} - - {preview.isText && preview.textPreview !== null && ( -
-
-                        {preview.textPreview}
-                      
-
- )} - - {!preview.isText && !preview.isImage && ( -
- Preview not available for this file type. -
- )} -
- )} - - {selectedFile && !previewLoading && !preview && ( -
- -

- Failed to load preview -

-
- )} -
-
-
-
-
- ); -} diff --git a/apps/dev-playground/client/src/routes/index.tsx b/apps/dev-playground/client/src/routes/index.tsx index a34ddbae..58a6f410 100644 --- a/apps/dev-playground/client/src/routes/index.tsx +++ b/apps/dev-playground/client/src/routes/index.tsx @@ -126,24 +126,6 @@ function IndexRoute() {
- -
-

- File Browser -

-

- Browse, preview, and download files from Databricks Volumes - using the Files plugin and Unity Catalog Files API. -

- -
-
-

From 91367c2540a033f9a333fc62203d0c8d4e200bd4 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 17:35:52 +0100 Subject: [PATCH 09/23] docs: update --- .../api/appkit/Function.contentTypeFromPath.md | 16 ---------------- docs/docs/api/appkit/index.md | 1 - docs/docs/api/appkit/typedoc-sidebar.ts | 5 ----- packages/appkit/src/index.ts | 1 - 4 files changed, 23 deletions(-) delete mode 100644 docs/docs/api/appkit/Function.contentTypeFromPath.md diff --git a/docs/docs/api/appkit/Function.contentTypeFromPath.md b/docs/docs/api/appkit/Function.contentTypeFromPath.md deleted file mode 100644 index 7b341954..00000000 --- a/docs/docs/api/appkit/Function.contentTypeFromPath.md +++ /dev/null @@ -1,16 +0,0 @@ -# Function: contentTypeFromPath() - -```ts -function contentTypeFromPath(filePath: string, reported?: string): string; -``` - -## Parameters - -| Parameter | Type | -| ------ | ------ | -| `filePath` | `string` | -| `reported?` | `string` | - -## Returns - -`string` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 182936b5..91f9a66e 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -65,7 +65,6 @@ plugin architecture, and React integration. | Function | Description | | ------ | ------ | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | -| [contentTypeFromPath](Function.contentTypeFromPath.md) | - | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 63b513c2..3421d7ee 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -200,11 +200,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.appKitTypesPlugin", label: "appKitTypesPlugin" }, - { - type: "doc", - id: "api/appkit/Function.contentTypeFromPath", - label: "contentTypeFromPath" - }, { type: "doc", id: "api/appkit/Function.createApp", diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index c8f0d7e5..ec729d33 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -47,7 +47,6 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, files, server } from "./plugins"; -export { contentTypeFromPath } from "./plugins/files"; // Registry types and utilities for plugin manifests export type { ConfigSchema, From da0e62f1d6dd29254ad2ce5db51f62ab896b4387 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 10:13:34 +0100 Subject: [PATCH 10/23] chore: cleanup integration tests --- .../files/tests/plugin.integration.test.ts | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index ef942aca..992ff9eb 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -13,10 +13,8 @@ import { ServiceContext } from "../../../context/service-context"; import { createApp } from "../../../core"; import { server as serverPlugin } from "../../server"; import { files } from "../index"; +import { streamFromString } from "./utils"; -// --------------------------------------------------------------------------- -// Hoisted mocks -// --------------------------------------------------------------------------- const { mockFilesApi, mockSdkClient, MockApiError } = vi.hoisted(() => { const mockFilesApi = { listDirectoryContents: vi.fn(), @@ -50,7 +48,6 @@ const { mockFilesApi, mockSdkClient, MockApiError } = vi.hoisted(() => { return { mockFilesApi, mockSdkClient, MockApiError }; }); -// Mock SDK so `ApiError` instanceof checks work in FilesClient.exists() vi.mock("@databricks/sdk-experimental", async (importOriginal) => { const actual = await importOriginal(); @@ -60,28 +57,11 @@ vi.mock("@databricks/sdk-experimental", async (importOriginal) => { }; }); -// --------------------------------------------------------------------------- -// Helper: create a ReadableStream from a string -// --------------------------------------------------------------------------- -function streamFromString(text: string): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(text)); - controller.close(); - }, - }); -} - -// Headers to simulate authenticated user requests (required by asUser) const authHeaders = { "x-forwarded-access-token": "test-token", "x-forwarded-user": "test-user", }; -// --------------------------------------------------------------------------- -// Integration tests -// --------------------------------------------------------------------------- describe("Files Plugin Integration", () => { let server: Server; let baseUrl: string; @@ -108,7 +88,6 @@ describe("Files Plugin Integration", () => { ], }); - // Routes are now auto-registered by the files plugin via injectRoutes await appkit.server.start(); server = appkit.server.getServer(); baseUrl = `http://127.0.0.1:${TEST_PORT}`; From 09f86c3fddeae3af409dd3060875ab14d59db533 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 15:22:20 +0100 Subject: [PATCH 11/23] fix: add file resource to manifest --- packages/appkit/src/index.ts | 1 + packages/appkit/src/plugins/files/manifest.json | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index ec729d33..9bb68f0c 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -47,6 +47,7 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, files, server } from "./plugins"; +export { contentTypeFromPath } from "./plugins/files/helpers"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/files/manifest.json b/packages/appkit/src/plugins/files/manifest.json index c17811bd..2bf5a4f7 100644 --- a/packages/appkit/src/plugins/files/manifest.json +++ b/packages/appkit/src/plugins/files/manifest.json @@ -4,7 +4,21 @@ "displayName": "Files Plugin", "description": "File operations against Databricks Volumes and Unity Catalog", "resources": { - "required": [], + "required": [ + { + "type": "volume", + "alias": "volume", + "resourceKey": "volume", + "description": "Unity Catalog Volume for file storage", + "permission": "WRITE_VOLUME", + "fields": { + "path": { + "env": "DATABRICKS_VOLUME_PATH", + "description": "Volume path (e.g. /Volumes/catalog/schema/volume_name)" + } + } + } + ], "optional": [] }, "config": { From 79bd1738c983288a074ef42cf7d0543171df4a01 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 15:46:20 +0100 Subject: [PATCH 12/23] refactor: move client to `/connectors` --- .../appkit/src/connectors/files/client.ts | 7 +- .../appkit/src/connectors/files/defaults.ts | 33 +++ packages/appkit/src/connectors/files/index.ts | 2 + .../files/tests/client.test.ts} | 216 ++++++++++-------- packages/appkit/src/connectors/index.ts | 1 + packages/appkit/src/plugins/files/defaults.ts | 20 +- packages/appkit/src/plugins/files/helpers.ts | 16 +- packages/appkit/src/plugins/files/lib.ts | 201 ---------------- packages/appkit/src/plugins/files/plugin.ts | 59 ++--- .../src/plugins/files/tests/plugin.test.ts | 1 - 10 files changed, 189 insertions(+), 367 deletions(-) create mode 100644 packages/appkit/src/connectors/files/defaults.ts create mode 100644 packages/appkit/src/connectors/files/index.ts rename packages/appkit/src/{plugins/files/tests/lib.test.ts => connectors/files/tests/client.test.ts} (76%) delete mode 100644 packages/appkit/src/plugins/files/lib.ts diff --git a/packages/appkit/src/connectors/files/client.ts b/packages/appkit/src/connectors/files/client.ts index 41c39f0a..2acb354d 100644 --- a/packages/appkit/src/connectors/files/client.ts +++ b/packages/appkit/src/connectors/files/client.ts @@ -220,6 +220,7 @@ export class FilesConnector { response["content-type"], this.customContentTypes, ), + contentType: contentTypeFromPath(filePath, response["content-type"]), lastModified: response["last-modified"], }; }, @@ -307,7 +308,11 @@ export class FilesConnector { { "files.path": this.resolvePath(filePath) }, async () => { const meta = await this.metadata(client, filePath); - const isText = isTextContentType(meta.contentType); + const isText = + meta.contentType?.startsWith("text/") || + meta.contentType === "application/json" || + meta.contentType === "application/xml" || + false; const isImage = meta.contentType?.startsWith("image/") || false; if (!isText) { diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts new file mode 100644 index 00000000..67ec71c4 --- /dev/null +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -0,0 +1,33 @@ +export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".json": "application/json", + ".xml": "application/xml", + ".html": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".txt": "text/plain", + ".md": "text/markdown", + ".csv": "text/csv", + ".pdf": "application/pdf", +}); + +export function contentTypeFromPath( + filePath: string, + reported?: string, +): string { + const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); + const fromExt = EXTENSION_CONTENT_TYPES[ext]; + + if (fromExt) { + return fromExt; + } + + return reported ?? "application/octet-stream"; +} diff --git a/packages/appkit/src/connectors/files/index.ts b/packages/appkit/src/connectors/files/index.ts new file mode 100644 index 00000000..6f0217a1 --- /dev/null +++ b/packages/appkit/src/connectors/files/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./defaults"; diff --git a/packages/appkit/src/plugins/files/tests/lib.test.ts b/packages/appkit/src/connectors/files/tests/client.test.ts similarity index 76% rename from packages/appkit/src/plugins/files/tests/lib.test.ts rename to packages/appkit/src/connectors/files/tests/client.test.ts index b9459c5e..1beff015 100644 --- a/packages/appkit/src/plugins/files/tests/lib.test.ts +++ b/packages/appkit/src/connectors/files/tests/client.test.ts @@ -1,7 +1,29 @@ import type { WorkspaceClient } from "@databricks/sdk-experimental"; +import { createMockTelemetry } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { FilesClient } from "../lib"; -import { streamFromChunks, streamFromString } from "./utils"; +import { FilesConnector } from "../client"; + +function streamFromString(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); +} const { mockFilesApi, mockConfig, mockClient, MockApiError } = vi.hoisted( () => { @@ -42,20 +64,29 @@ vi.mock("@databricks/sdk-experimental", () => ({ ApiError: MockApiError, })); -describe("FilesClient", () => { +const mockTelemetry = createMockTelemetry(); + +vi.mock("../../../telemetry", () => ({ + TelemetryManager: { + getProvider: vi.fn(() => mockTelemetry), + }, + SpanKind: { CLIENT: 2 }, + SpanStatusCode: { OK: 1, ERROR: 2 }, +})); + +describe("FilesConnector", () => { describe("Path Resolution", () => { beforeEach(() => { vi.clearAllMocks(); }); test("absolute paths are returned as-is", () => { - const client = new FilesClient({ + const connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient, }); mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("/Volumes/other/path/file.txt"); + connector.download(mockClient, "/Volumes/other/path/file.txt"); expect(mockFilesApi.download).toHaveBeenCalledWith({ file_path: "/Volumes/other/path/file.txt", @@ -63,13 +94,12 @@ describe("FilesClient", () => { }); test("relative paths prepend defaultVolume", () => { - const client = new FilesClient({ + const connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient, }); mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("subdir/file.txt"); + connector.download(mockClient, "subdir/file.txt"); expect(mockFilesApi.download).toHaveBeenCalledWith({ file_path: "/Volumes/catalog/schema/vol/subdir/file.txt", @@ -77,39 +107,37 @@ describe("FilesClient", () => { }); test("relative path without defaultVolume throws error", async () => { - const client = new FilesClient({ client: mockClient as any }); + const connector = new FilesConnector({}); - await expect(client.download("file.txt")).rejects.toThrow( + await expect(connector.download(mockClient, "file.txt")).rejects.toThrow( "Cannot resolve relative path: no default volume set.", ); }); - test("volume() creates new client scoped to a different volume", () => { - const client = new FilesClient({ + test("volume() creates new connector scoped to a different volume", () => { + const connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol1", - client: mockClient as any, }); - const scoped = client.volume("/Volumes/catalog/schema/vol2"); + const scoped = connector.volume("/Volumes/catalog/schema/vol2"); mockFilesApi.download.mockResolvedValue({ contents: null }); - scoped.download("file.txt"); + scoped.download(mockClient, "file.txt"); expect(mockFilesApi.download).toHaveBeenCalledWith({ file_path: "/Volumes/catalog/schema/vol2/file.txt", }); }); - test("volume() does not affect the original client", () => { - const client = new FilesClient({ + test("volume() does not affect the original connector", () => { + const connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol1", - client: mockClient, }); - client.volume("/Volumes/catalog/schema/vol2"); + connector.volume("/Volumes/catalog/schema/vol2"); mockFilesApi.download.mockResolvedValue({ contents: null }); - client.download("file.txt"); + connector.download(mockClient, "file.txt"); expect(mockFilesApi.download).toHaveBeenCalledWith({ file_path: "/Volumes/catalog/schema/vol1/file.txt", @@ -117,22 +145,21 @@ describe("FilesClient", () => { }); test("constructor without defaultVolume omits it", async () => { - const client = new FilesClient({ client: mockClient as any }); + const connector = new FilesConnector({}); - await expect(client.list()).rejects.toThrow( + await expect(connector.list(mockClient)).rejects.toThrow( "No directory path provided and no default volume set.", ); }); }); describe("list()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); @@ -158,7 +185,7 @@ describe("FilesClient", () => { })(), ); - const result = await client.list(); + const result = await connector.list(mockClient); expect(result).toEqual(entries); expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ @@ -171,7 +198,7 @@ describe("FilesClient", () => { (async function* () {})(), ); - await client.list(); + await connector.list(mockClient); expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ directory_path: "/Volumes/catalog/schema/vol", @@ -179,9 +206,9 @@ describe("FilesClient", () => { }); test("throws when no path and no defaultVolume", async () => { - const noVolumeClient = new FilesClient({ client: mockClient as any }); + const noVolumeConnector = new FilesConnector({}); - await expect(noVolumeClient.list()).rejects.toThrow( + await expect(noVolumeConnector.list(mockClient)).rejects.toThrow( "No directory path provided and no default volume set.", ); }); @@ -191,7 +218,7 @@ describe("FilesClient", () => { (async function* () {})(), ); - await client.list("/Volumes/other/path"); + await connector.list(mockClient, "/Volumes/other/path"); expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ directory_path: "/Volumes/other/path", @@ -203,7 +230,7 @@ describe("FilesClient", () => { (async function* () {})(), ); - await client.list("subdir"); + await connector.list(mockClient, "subdir"); expect(mockFilesApi.listDirectoryContents).toHaveBeenCalledWith({ directory_path: "/Volumes/catalog/schema/vol/subdir", @@ -215,20 +242,19 @@ describe("FilesClient", () => { (async function* () {})(), ); - const result = await client.list(); + const result = await connector.list(mockClient); expect(result).toEqual([]); }); }); describe("read()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); @@ -238,7 +264,7 @@ describe("FilesClient", () => { contents: streamFromString(content), }); - const result = await client.read("/file.txt"); + const result = await connector.read(mockClient, "/file.txt"); expect(result).toBe(content); }); @@ -246,7 +272,7 @@ describe("FilesClient", () => { test("returns empty string when contents is null", async () => { mockFilesApi.download.mockResolvedValue({ contents: null }); - const result = await client.read("/empty.txt"); + const result = await connector.read(mockClient, "/empty.txt"); expect(result).toBe(""); }); @@ -256,7 +282,7 @@ describe("FilesClient", () => { contents: streamFromChunks(["Hello, ", "world", "!"]), }); - const result = await client.read("/chunked.txt"); + const result = await connector.read(mockClient, "/chunked.txt"); expect(result).toBe("Hello, world!"); }); @@ -267,20 +293,19 @@ describe("FilesClient", () => { contents: streamFromString(content), }); - const result = await client.read("/unicode.txt"); + const result = await connector.read(mockClient, "/unicode.txt"); expect(result).toBe(content); }); }); describe("download()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); @@ -288,7 +313,7 @@ describe("FilesClient", () => { const response = { contents: streamFromString("data") }; mockFilesApi.download.mockResolvedValue(response); - const result = await client.download("file.txt"); + const result = await connector.download(mockClient, "file.txt"); expect(mockFilesApi.download).toHaveBeenCalledWith({ file_path: "/Volumes/catalog/schema/vol/file.txt", @@ -300,7 +325,7 @@ describe("FilesClient", () => { const response = { contents: null }; mockFilesApi.download.mockResolvedValue(response); - await client.download("/Volumes/other/file.txt"); + await connector.download(mockClient, "/Volumes/other/file.txt"); expect(mockFilesApi.download).toHaveBeenCalledWith({ file_path: "/Volumes/other/file.txt", @@ -309,13 +334,12 @@ describe("FilesClient", () => { }); describe("exists()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); @@ -326,7 +350,7 @@ describe("FilesClient", () => { "last-modified": "2025-01-01", }); - const result = await client.exists("/file.txt"); + const result = await connector.exists(mockClient, "/file.txt"); expect(result).toBe(true); }); @@ -336,7 +360,7 @@ describe("FilesClient", () => { new MockApiError("Not found", 404), ); - const result = await client.exists("/missing.txt"); + const result = await connector.exists(mockClient, "/missing.txt"); expect(result).toBe(false); }); @@ -346,26 +370,27 @@ describe("FilesClient", () => { new MockApiError("Server error", 500), ); - await expect(client.exists("/file.txt")).rejects.toThrow("Server error"); + await expect(connector.exists(mockClient, "/file.txt")).rejects.toThrow( + "Server error", + ); }); test("rethrows generic errors", async () => { mockFilesApi.getMetadata.mockRejectedValue(new Error("Network failure")); - await expect(client.exists("/file.txt")).rejects.toThrow( + await expect(connector.exists(mockClient, "/file.txt")).rejects.toThrow( "Network failure", ); }); }); describe("metadata()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); @@ -376,7 +401,7 @@ describe("FilesClient", () => { "last-modified": "2025-06-15T10:00:00Z", }); - const result = await client.metadata("/data.json"); + const result = await connector.metadata(mockClient, "/data.json"); expect(result).toEqual({ contentLength: 1234, @@ -392,7 +417,7 @@ describe("FilesClient", () => { "last-modified": "2025-01-01", }); - const result = await client.metadata("/image.png"); + const result = await connector.metadata(mockClient, "/image.png"); expect(result.contentType).toBe("image/png"); }); @@ -404,7 +429,7 @@ describe("FilesClient", () => { "last-modified": "2025-01-01", }); - const result = await client.metadata("/data.csv"); + const result = await connector.metadata(mockClient, "/data.csv"); expect(result.contentType).toBe("text/csv"); }); @@ -416,7 +441,7 @@ describe("FilesClient", () => { "last-modified": "2025-01-01", }); - await client.metadata("notes.txt"); + await connector.metadata(mockClient, "notes.txt"); expect(mockFilesApi.getMetadata).toHaveBeenCalledWith({ file_path: "/Volumes/catalog/schema/vol/notes.txt", @@ -425,14 +450,13 @@ describe("FilesClient", () => { }); describe("upload()", () => { - let client: FilesClient; + let connector: FilesConnector; let fetchSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); mockConfig.authenticate.mockResolvedValue(undefined); fetchSpy = vi.fn().mockResolvedValue({ ok: true }); @@ -444,7 +468,7 @@ describe("FilesClient", () => { }); test("handles string input", async () => { - await client.upload("file.txt", "hello world"); + await connector.upload(mockClient, "file.txt", "hello world"); expect(fetchSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -459,7 +483,7 @@ describe("FilesClient", () => { test("handles Buffer input", async () => { const buf = Buffer.from("buffer data"); - await client.upload("file.bin", buf); + await connector.upload(mockClient, "file.bin", buf); expect(fetchSpy).toHaveBeenCalledWith( expect.any(String), @@ -472,7 +496,7 @@ describe("FilesClient", () => { test("handles ReadableStream input (streams directly)", async () => { const stream = streamFromString("stream data"); - await client.upload("file.txt", stream); + await connector.upload(mockClient, "file.txt", stream); expect(fetchSpy).toHaveBeenCalledWith( expect.any(String), @@ -485,27 +509,29 @@ describe("FilesClient", () => { }); test("defaults overwrite to true", async () => { - await client.upload("file.txt", "data"); + await connector.upload(mockClient, "file.txt", "data"); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain("overwrite=true"); }); test("sets overwrite=false when specified", async () => { - await client.upload("file.txt", "data", { overwrite: false }); + await connector.upload(mockClient, "file.txt", "data", { + overwrite: false, + }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain("overwrite=false"); }); test("calls config.authenticate on the headers", async () => { - await client.upload("file.txt", "data"); + await connector.upload(mockClient, "file.txt", "data"); expect(mockConfig.authenticate).toHaveBeenCalledWith(expect.any(Headers)); }); test("builds URL from client.config.host", async () => { - await client.upload("file.txt", "data"); + await connector.upload(mockClient, "file.txt", "data"); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch( @@ -520,13 +546,13 @@ describe("FilesClient", () => { text: () => Promise.resolve("Forbidden"), }); - await expect(client.upload("file.txt", "data")).rejects.toThrow( - "Upload failed (403): Forbidden", - ); + await expect( + connector.upload(mockClient, "file.txt", "data"), + ).rejects.toThrow("Upload failed (403): Forbidden"); }); test("resolves absolute paths directly", async () => { - await client.upload("/Volumes/other/vol/file.txt", "data"); + await connector.upload(mockClient, "/Volumes/other/vol/file.txt", "data"); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toContain("/api/2.0/fs/files/Volumes/other/vol/file.txt"); @@ -534,20 +560,19 @@ describe("FilesClient", () => { }); describe("createDirectory()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); test("calls client.files.createDirectory with resolved path", async () => { mockFilesApi.createDirectory.mockResolvedValue(undefined); - await client.createDirectory("new-dir"); + await connector.createDirectory(mockClient, "new-dir"); expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ directory_path: "/Volumes/catalog/schema/vol/new-dir", @@ -557,7 +582,10 @@ describe("FilesClient", () => { test("uses absolute path when provided", async () => { mockFilesApi.createDirectory.mockResolvedValue(undefined); - await client.createDirectory("/Volumes/other/path/new-dir"); + await connector.createDirectory( + mockClient, + "/Volumes/other/path/new-dir", + ); expect(mockFilesApi.createDirectory).toHaveBeenCalledWith({ directory_path: "/Volumes/other/path/new-dir", @@ -566,20 +594,19 @@ describe("FilesClient", () => { }); describe("delete()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); test("calls client.files.delete with resolved path", async () => { mockFilesApi.delete.mockResolvedValue(undefined); - await client.delete("file.txt"); + await connector.delete(mockClient, "file.txt"); expect(mockFilesApi.delete).toHaveBeenCalledWith({ file_path: "/Volumes/catalog/schema/vol/file.txt", @@ -589,7 +616,7 @@ describe("FilesClient", () => { test("uses absolute path when provided", async () => { mockFilesApi.delete.mockResolvedValue(undefined); - await client.delete("/Volumes/other/file.txt"); + await connector.delete(mockClient, "/Volumes/other/file.txt"); expect(mockFilesApi.delete).toHaveBeenCalledWith({ file_path: "/Volumes/other/file.txt", @@ -598,13 +625,12 @@ describe("FilesClient", () => { }); describe("preview()", () => { - let client: FilesClient; + let connector: FilesConnector; beforeEach(() => { vi.clearAllMocks(); - client = new FilesClient({ + connector = new FilesConnector({ defaultVolume: "/Volumes/catalog/schema/vol", - client: mockClient as any, }); }); @@ -620,7 +646,7 @@ describe("FilesClient", () => { contents: streamFromString(longText), }); - const result = await client.preview("/file.txt"); + const result = await connector.preview(mockClient, "/file.txt"); expect(result.isText).toBe(true); expect(result.isImage).toBe(false); @@ -638,7 +664,7 @@ describe("FilesClient", () => { contents: streamFromString("

Hello

"), }); - const result = await client.preview("/page.html"); + const result = await connector.preview(mockClient, "/page.html"); expect(result.isText).toBe(true); expect(result.textPreview).toBe("

Hello

"); @@ -654,7 +680,7 @@ describe("FilesClient", () => { contents: streamFromString('{"key":"value"}'), }); - const result = await client.preview("/data.json"); + const result = await connector.preview(mockClient, "/data.json"); expect(result.isText).toBe(true); expect(result.textPreview).toBe('{"key":"value"}'); @@ -670,7 +696,7 @@ describe("FilesClient", () => { contents: streamFromString(""), }); - const result = await client.preview("/data.xml"); + const result = await connector.preview(mockClient, "/data.xml"); expect(result.isText).toBe(true); expect(result.textPreview).toBe(""); @@ -683,7 +709,7 @@ describe("FilesClient", () => { "last-modified": "2025-01-01", }); - const result = await client.preview("/image.png"); + const result = await connector.preview(mockClient, "/image.png"); expect(result.isImage).toBe(true); expect(result.isText).toBe(false); @@ -697,7 +723,7 @@ describe("FilesClient", () => { "last-modified": "2025-01-01", }); - const result = await client.preview("/doc.pdf"); + const result = await connector.preview(mockClient, "/doc.pdf"); expect(result.isText).toBe(false); expect(result.isImage).toBe(false); @@ -714,7 +740,7 @@ describe("FilesClient", () => { contents: null, }); - const result = await client.preview("/empty.txt"); + const result = await connector.preview(mockClient, "/empty.txt"); expect(result.isText).toBe(true); expect(result.isImage).toBe(false); @@ -731,7 +757,7 @@ describe("FilesClient", () => { contents: streamFromString("hello"), }); - const result = await client.preview("/notes.txt"); + const result = await connector.preview(mockClient, "/notes.txt"); expect(result.contentLength).toBe(42); expect(result.contentType).toBe("text/plain"); @@ -750,7 +776,7 @@ describe("FilesClient", () => { contents: streamFromString(content), }); - const result = await client.preview("/short.txt"); + const result = await connector.preview(mockClient, "/short.txt"); expect(result.textPreview).toBe(content); }); diff --git a/packages/appkit/src/connectors/index.ts b/packages/appkit/src/connectors/index.ts index fdb1cc69..c20c794a 100644 --- a/packages/appkit/src/connectors/index.ts +++ b/packages/appkit/src/connectors/index.ts @@ -1,3 +1,4 @@ export * from "./lakebase"; +export * from "./files"; export * from "./lakebase-v1"; export * from "./sql-warehouse"; diff --git a/packages/appkit/src/plugins/files/defaults.ts b/packages/appkit/src/plugins/files/defaults.ts index 80d54d5a..95a620d8 100644 --- a/packages/appkit/src/plugins/files/defaults.ts +++ b/packages/appkit/src/plugins/files/defaults.ts @@ -38,22 +38,4 @@ export const filesWriteDefaults: PluginExecuteConfig = { timeout: 600_000, }; -export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".json": "application/json", - ".xml": "application/xml", - ".html": "text/html", - ".css": "text/css", - ".js": "text/javascript", - ".txt": "text/plain", - ".md": "text/markdown", - ".csv": "text/csv", - ".pdf": "application/pdf", -}); +export { EXTENSION_CONTENT_TYPES } from "../../connectors/files/defaults"; diff --git a/packages/appkit/src/plugins/files/helpers.ts b/packages/appkit/src/plugins/files/helpers.ts index f0160b02..e8d96505 100644 --- a/packages/appkit/src/plugins/files/helpers.ts +++ b/packages/appkit/src/plugins/files/helpers.ts @@ -1,15 +1 @@ -import { EXTENSION_CONTENT_TYPES } from "./defaults"; - -export function contentTypeFromPath( - filePath: string, - reported?: string, -): string { - const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); - const fromExt = EXTENSION_CONTENT_TYPES[ext]; - - if (fromExt) { - return fromExt; - } - - return reported ?? "application/octet-stream"; -} +export { contentTypeFromPath } from "../../connectors/files/defaults"; diff --git a/packages/appkit/src/plugins/files/lib.ts b/packages/appkit/src/plugins/files/lib.ts deleted file mode 100644 index acc2daa4..00000000 --- a/packages/appkit/src/plugins/files/lib.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { ApiError, WorkspaceClient } from "@databricks/sdk-experimental"; -import { createLogger } from "@/logging/logger"; -import { contentTypeFromPath } from "./helpers"; -import type { - DirectoryEntry, - DownloadResponse, - FileMetadata, - FilePreview, -} from "./types"; - -const logger = createLogger("files"); - -export class FilesClient { - private client: WorkspaceClient; - private defaultVolume: string | undefined; - - constructor({ - defaultVolume, - client, - }: { - defaultVolume?: string; - client?: WorkspaceClient; - }) { - this.client = client ?? new WorkspaceClient({}); - if (defaultVolume) { - this.defaultVolume = defaultVolume; - } - } - - private resolvePath(filePath: string): string { - if (filePath.startsWith("/")) { - return filePath; - } - if (!this.defaultVolume) { - throw new Error( - "Cannot resolve relative path: no default volume set. Use an absolute path or set a default volume.", - ); - } - return `${this.defaultVolume}/${filePath}`; - } - - volume(volumePath: string): FilesClient { - return new FilesClient({ defaultVolume: volumePath, client: this.client }); - } - - async list(directoryPath?: string): Promise { - const resolvedPath = directoryPath - ? this.resolvePath(directoryPath) - : this.defaultVolume; - if (!resolvedPath) { - throw new Error("No directory path provided and no default volume set."); - } - const entries: DirectoryEntry[] = []; - for await (const entry of this.client.files.listDirectoryContents({ - directory_path: resolvedPath, - })) { - entries.push(entry); - } - return entries; - } - - async read(filePath: string): Promise { - const response = await this.download(filePath); - if (!response.contents) { - return ""; - } - const reader = response.contents.getReader(); - const decoder = new TextDecoder(); - let result = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - result += decoder.decode(value, { stream: true }); - } - result += decoder.decode(); - return result; - } - - async download(filePath: string): Promise { - return this.client.files.download({ - file_path: this.resolvePath(filePath), - }); - } - - async exists(filePath: string): Promise { - try { - await this.metadata(filePath); - return true; - } catch (error) { - if (error instanceof ApiError && error.statusCode === 404) { - return false; - } - throw error; - } - } - - async metadata(filePath: string): Promise { - const response = await this.client.files.getMetadata({ - file_path: this.resolvePath(filePath), - }); - return { - contentLength: response["content-length"], - contentType: contentTypeFromPath(filePath, response["content-type"]), - lastModified: response["last-modified"], - }; - } - - async upload( - filePath: string, - contents: ReadableStream | Buffer | string, - options?: { overwrite?: boolean }, - ): Promise { - const body = contents; - - const resolvedPath = this.resolvePath(filePath); - const overwrite = options?.overwrite ?? true; - - // Workaround: The SDK's files.upload() has two bugs: - // 1. It ignores the `contents` field (sets body to undefined) - // 2. apiClient.request() checks `instanceof` against its own ReadableStream - // subclass, so standard ReadableStream instances get JSON.stringified to "{}" - // Bypass both by calling the REST API directly with SDK-provided auth. - const url = new URL( - `/api/2.0/fs/files${resolvedPath}`, - this.client.config.host, - ); - url.searchParams.set("overwrite", String(overwrite)); - - const headers = new Headers({ "Content-Type": "application/octet-stream" }); - const fetchOptions: RequestInit = { method: "PUT", headers, body }; - - if (body instanceof ReadableStream) { - fetchOptions.duplex = "half"; - } - - await this.client.config.authenticate(headers); - - const res = await fetch(url.toString(), fetchOptions); - - if (!res.ok) { - const text = await res.text(); - logger.error(`Upload failed (${res.status}): ${text}`); - throw new Error(`Upload failed (${res.status}): ${text}`); - } - } - - async createDirectory(directoryPath: string): Promise { - await this.client.files.createDirectory({ - directory_path: this.resolvePath(directoryPath), - }); - } - - async delete(filePath: string): Promise { - await this.client.files.delete({ - file_path: this.resolvePath(filePath), - }); - } - - async preview( - filePath: string, - options?: { maxBytes?: number }, - ): Promise { - const meta = await this.metadata(filePath); - const isText = - meta.contentType?.startsWith("text/") || - meta.contentType === "application/json" || - meta.contentType === "application/xml" || - false; - const isImage = meta.contentType?.startsWith("image/") || false; - - if (!isText) { - return { ...meta, textPreview: null, isText: false, isImage }; - } - - const response = await this.client.files.download({ - file_path: this.resolvePath(filePath), - }); - if (!response.contents) { - return { ...meta, textPreview: "", isText: true, isImage: false }; - } - - const reader = response.contents.getReader(); - const decoder = new TextDecoder(); - let preview = ""; - const maxBytes = options?.maxBytes ?? 1024; - - while (preview.length < maxBytes) { - const { done, value } = await reader.read(); - if (done) break; - preview += decoder.decode(value, { stream: true }); - } - preview += decoder.decode(); - await reader.cancel(); - - if (preview.length > maxBytes) { - preview = preview.slice(0, maxBytes); - } - - return { ...meta, textPreview: preview, isText: true, isImage: false }; - } -} diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 2909090a..c80a3ac7 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -1,6 +1,7 @@ import { Readable } from "node:stream"; import type express from "express"; import type { IAppRouter, PluginExecutionSettings } from "shared"; +import { contentTypeFromPath, FilesConnector } from "../../connectors/files"; import { getCurrentUserId, getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; @@ -9,8 +10,6 @@ import { filesReadDefaults, filesWriteDefaults, } from "./defaults"; -import { contentTypeFromPath } from "./helpers"; -import { FilesClient } from "./lib"; import { filesManifest } from "./manifest"; import type { DownloadResponse, IFilesConfig } from "./types"; @@ -23,43 +22,36 @@ export class FilesPlugin extends Plugin { protected static description = "Files plugin for Databricks file operations"; protected declare config: IFilesConfig; + private filesConnector: FilesConnector; + constructor(config: IFilesConfig) { super(config); this.config = config; - } - - /** - * Create a FilesClient scoped to the current execution context. - * Must be called per-request so `asUser()` context is respected. - */ - private getFilesClient(): FilesClient { - const client = getWorkspaceClient(); - return new FilesClient({ - defaultVolume: this.config.defaultVolume, - client, + this.filesConnector = new FilesConnector({ + defaultVolume: config.defaultVolume, + timeout: config.timeout, + telemetry: config.telemetry, }); } - // --- Public methods (proxied by asUser) --- - async list(directoryPath?: string) { - return this.getFilesClient().list(directoryPath); + return this.filesConnector.list(getWorkspaceClient(), directoryPath); } async read(filePath: string) { - return this.getFilesClient().read(filePath); + return this.filesConnector.read(getWorkspaceClient(), filePath); } async download(filePath: string): Promise { - return this.getFilesClient().download(filePath); + return this.filesConnector.download(getWorkspaceClient(), filePath); } async exists(filePath: string) { - return this.getFilesClient().exists(filePath); + return this.filesConnector.exists(getWorkspaceClient(), filePath); } async metadata(filePath: string) { - return this.getFilesClient().metadata(filePath); + return this.filesConnector.metadata(getWorkspaceClient(), filePath); } async upload( @@ -67,23 +59,29 @@ export class FilesPlugin extends Plugin { contents: ReadableStream | Buffer | string, options?: { overwrite?: boolean }, ) { - return this.getFilesClient().upload(filePath, contents, options); + return this.filesConnector.upload( + getWorkspaceClient(), + filePath, + contents, + options, + ); } async createDirectory(directoryPath: string) { - return this.getFilesClient().createDirectory(directoryPath); + return this.filesConnector.createDirectory( + getWorkspaceClient(), + directoryPath, + ); } async delete(filePath: string) { - return this.getFilesClient().delete(filePath); + return this.filesConnector.delete(getWorkspaceClient(), filePath); } async preview(filePath: string) { - return this.getFilesClient().preview(filePath); + return this.filesConnector.preview(getWorkspaceClient(), filePath); } - // --- Routes --- - injectRoutes(router: IAppRouter) { this.route(router, { name: "root", @@ -186,8 +184,6 @@ export class FilesPlugin extends Plugin { }); } - // --- Private route handlers --- - private _readSettings( cacheKey: (string | number | object)[], ): PluginExecutionSettings { @@ -414,13 +410,6 @@ export class FilesPlugin extends Plugin { logger.debug(req, "Upload started: path=%s", path); - // const body = await new Promise((resolve, reject) => { - // const chunks: Buffer[] = []; - // req.on("data", (chunk: Buffer) => chunks.push(chunk)); - // req.on("end", () => resolve(Buffer.concat(chunks))); - // req.on("error", reject); - // }); - const webStream: ReadableStream = Readable.toWeb(req); logger.debug( diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index 1ff000de..13df9b92 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -1,7 +1,6 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../../context/service-context"; -import { FilesClient } from "../lib"; import { FilesPlugin, files } from "../plugin"; import { streamFromString } from "./utils"; From f4826dc49701f898c7564013918e8b506caa0adc Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 16:22:53 +0100 Subject: [PATCH 13/23] chore: improve preview and MIME checks --- .../appkit/src/connectors/files/client.ts | 8 +- .../appkit/src/connectors/files/defaults.ts | 31 ++++++- packages/appkit/src/plugins/files/helpers.ts | 5 +- packages/appkit/src/plugins/files/plugin.ts | 5 +- .../src/plugins/files/tests/helpers.test.ts | 90 ++++++++++++++++++- .../files/tests/plugin.integration.test.ts | 5 +- packages/appkit/src/plugins/files/types.ts | 1 + 7 files changed, 128 insertions(+), 17 deletions(-) diff --git a/packages/appkit/src/connectors/files/client.ts b/packages/appkit/src/connectors/files/client.ts index 2acb354d..ed9e4533 100644 --- a/packages/appkit/src/connectors/files/client.ts +++ b/packages/appkit/src/connectors/files/client.ts @@ -16,7 +16,7 @@ import { SpanStatusCode, TelemetryManager, } from "../../telemetry"; -import { contentTypeFromPath } from "./defaults"; +import { contentTypeFromPath, isTextContentType } from "./defaults"; const logger = createLogger("connectors:files"); @@ -308,11 +308,7 @@ export class FilesConnector { { "files.path": this.resolvePath(filePath) }, async () => { const meta = await this.metadata(client, filePath); - const isText = - meta.contentType?.startsWith("text/") || - meta.contentType === "application/json" || - meta.contentType === "application/xml" || - false; + const isText = isTextContentType(meta.contentType); const isImage = meta.contentType?.startsWith("image/") || false; if (!isText) { diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts index 67ec71c4..9685a5fd 100644 --- a/packages/appkit/src/connectors/files/defaults.ts +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -6,23 +6,48 @@ export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".json": "application/json", - ".xml": "application/xml", + ".ico": "image/vnd.microsoft.icon", ".html": "text/html", ".css": "text/css", ".js": "text/javascript", + ".ts": "text/typescript", + ".py": "text/x-python", ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", + ".json": "application/json", + ".jsonl": "application/x-ndjson", + ".xml": "application/xml", + ".yaml": "application/x-yaml", + ".yml": "application/x-yaml", + ".sql": "application/sql", ".pdf": "application/pdf", + ".ipynb": "application/x-ipynb+json", + ".parquet": "application/vnd.apache.parquet", + ".zip": "application/zip", + ".gz": "application/gzip", }); +const TEXT_KEYWORDS = ["json", "xml", "yaml", "sql", "javascript"] as const; + +export function isTextContentType(contentType: string | undefined): boolean { + if (!contentType) return false; + if (contentType.startsWith("text/")) return true; + return TEXT_KEYWORDS.some((kw) => contentType.includes(kw)); +} + export function contentTypeFromPath( filePath: string, reported?: string, + customTypes?: Record, ): string { const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); + const fromCustom = customTypes?.[ext]; + + if (fromCustom) { + return fromCustom; + } + const fromExt = EXTENSION_CONTENT_TYPES[ext]; if (fromExt) { diff --git a/packages/appkit/src/plugins/files/helpers.ts b/packages/appkit/src/plugins/files/helpers.ts index e8d96505..599368bf 100644 --- a/packages/appkit/src/plugins/files/helpers.ts +++ b/packages/appkit/src/plugins/files/helpers.ts @@ -1 +1,4 @@ -export { contentTypeFromPath } from "../../connectors/files/defaults"; +export { + contentTypeFromPath, + isTextContentType, +} from "../../connectors/files/defaults"; diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index c80a3ac7..68efeeec 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -31,6 +31,7 @@ export class FilesPlugin extends Plugin { defaultVolume: config.defaultVolume, timeout: config.timeout, telemetry: config.telemetry, + customContentTypes: config.customContentTypes, }); } @@ -277,7 +278,7 @@ export class FilesPlugin extends Plugin { res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); res.setHeader( "Content-Type", - contentTypeFromPath(path) ?? "application/octet-stream", + contentTypeFromPath(path, undefined, this.config.customContentTypes), ); if (response.contents) { const nodeStream = Readable.fromWeb( @@ -315,7 +316,7 @@ export class FilesPlugin extends Plugin { res.setHeader( "Content-Type", - contentTypeFromPath(path) ?? "application/octet-stream", + contentTypeFromPath(path, undefined, this.config.customContentTypes), ); if (response.contents) { const nodeStream = Readable.fromWeb( diff --git a/packages/appkit/src/plugins/files/tests/helpers.test.ts b/packages/appkit/src/plugins/files/tests/helpers.test.ts index 21bdd65d..d30154f5 100644 --- a/packages/appkit/src/plugins/files/tests/helpers.test.ts +++ b/packages/appkit/src/plugins/files/tests/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { contentTypeFromPath } from "../helpers"; +import { contentTypeFromPath, isTextContentType } from "../helpers"; describe("contentTypeFromPath", () => { test("works without reported type", () => { @@ -31,9 +31,91 @@ describe("contentTypeFromPath", () => { }); test("handles paths with multiple dots", () => { - expect(contentTypeFromPath("/archive.tar.gz")).toBe( - "application/octet-stream", - ); + expect(contentTypeFromPath("/archive.tar.gz")).toBe("application/gzip"); expect(contentTypeFromPath("/data.backup.json")).toBe("application/json"); }); + + test("resolves .ico to IANA standard type", () => { + expect(contentTypeFromPath("/favicon.ico")).toBe( + "image/vnd.microsoft.icon", + ); + }); + + test("resolves Databricks-relevant file types", () => { + expect(contentTypeFromPath("/config.yaml")).toBe("application/x-yaml"); + expect(contentTypeFromPath("/config.yml")).toBe("application/x-yaml"); + expect(contentTypeFromPath("/query.sql")).toBe("application/sql"); + expect(contentTypeFromPath("/data.parquet")).toBe( + "application/vnd.apache.parquet", + ); + expect(contentTypeFromPath("/events.jsonl")).toBe("application/x-ndjson"); + expect(contentTypeFromPath("/notebook.ipynb")).toBe( + "application/x-ipynb+json", + ); + expect(contentTypeFromPath("/script.py")).toBe("text/x-python"); + expect(contentTypeFromPath("/archive.zip")).toBe("application/zip"); + expect(contentTypeFromPath("/data.gz")).toBe("application/gzip"); + expect(contentTypeFromPath("/app.ts")).toBe("text/typescript"); + }); + + test("custom types override defaults", () => { + const custom = { ".json": "text/json", ".custom": "application/custom" }; + expect(contentTypeFromPath("/data.json", undefined, custom)).toBe( + "text/json", + ); + expect(contentTypeFromPath("/file.custom", undefined, custom)).toBe( + "application/custom", + ); + }); + + test("falls back to defaults when custom types don't match", () => { + const custom = { ".custom": "application/custom" }; + expect(contentTypeFromPath("/data.json", undefined, custom)).toBe( + "application/json", + ); + }); + + test("custom types take priority over reported type", () => { + const custom = { ".xyz": "application/xyz" }; + expect(contentTypeFromPath("/file.xyz", "text/plain", custom)).toBe( + "application/xyz", + ); + }); +}); + +describe("isTextContentType", () => { + test("returns false for undefined", () => { + expect(isTextContentType(undefined)).toBe(false); + }); + + test("returns true for text/* types", () => { + expect(isTextContentType("text/plain")).toBe(true); + expect(isTextContentType("text/html")).toBe(true); + expect(isTextContentType("text/markdown")).toBe(true); + expect(isTextContentType("text/x-python")).toBe(true); + expect(isTextContentType("text/typescript")).toBe(true); + }); + + test("returns true for text-based application/ types", () => { + expect(isTextContentType("application/json")).toBe(true); + expect(isTextContentType("application/xml")).toBe(true); + expect(isTextContentType("application/sql")).toBe(true); + expect(isTextContentType("application/javascript")).toBe(true); + expect(isTextContentType("application/x-yaml")).toBe(true); + expect(isTextContentType("application/x-ndjson")).toBe(true); + expect(isTextContentType("application/x-ipynb+json")).toBe(true); + }); + + test("returns false for binary application/ types", () => { + expect(isTextContentType("application/pdf")).toBe(false); + expect(isTextContentType("application/zip")).toBe(false); + expect(isTextContentType("application/gzip")).toBe(false); + expect(isTextContentType("application/octet-stream")).toBe(false); + expect(isTextContentType("application/vnd.apache.parquet")).toBe(false); + }); + + test("returns false for image types", () => { + expect(isTextContentType("image/png")).toBe(false); + expect(isTextContentType("image/jpeg")).toBe(false); + }); }); diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index 992ff9eb..8bed6db4 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -69,7 +69,9 @@ describe("Files Plugin Integration", () => { const TEST_PORT = 9880; beforeAll(async () => { - setupDatabricksEnv(); + setupDatabricksEnv({ + DATABRICKS_VOLUME_PATH: "/Volumes/catalog/schema/vol", + }); ServiceContext.reset(); serviceContextMock = await mockServiceContext({ @@ -94,6 +96,7 @@ describe("Files Plugin Integration", () => { }); afterAll(async () => { + delete process.env.DATABRICKS_VOLUME_PATH; serviceContextMock?.restore(); if (server) { await new Promise((resolve, reject) => { diff --git a/packages/appkit/src/plugins/files/types.ts b/packages/appkit/src/plugins/files/types.ts index 11982a32..578b7aee 100644 --- a/packages/appkit/src/plugins/files/types.ts +++ b/packages/appkit/src/plugins/files/types.ts @@ -4,6 +4,7 @@ import type { BasePluginConfig } from "shared"; export interface IFilesConfig extends BasePluginConfig { timeout?: number; defaultVolume?: string; + customContentTypes?: Record; } // TODO: Add request/response types for file operations From 60b1bcdd1462630d6af925c2a5a90deb3cbf64ff Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 11:22:45 +0100 Subject: [PATCH 14/23] chore: fix merge conflicts --- .../client/src/routeTree.gen.ts | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 71b4f0e5..2b92f113 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -13,11 +13,7 @@ import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' -<<<<<<< HEAD import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' -import { Route as FilesRouteRouteImport } from './routes/files.route' -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) import { Route as DataVisualizationRouteRouteImport } from './routes/data-visualization.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' @@ -43,19 +39,11 @@ const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ path: '/reconnect', getParentRoute: () => rootRouteImport, } as any) -<<<<<<< HEAD const LakebaseRouteRoute = LakebaseRouteRouteImport.update({ id: '/lakebase', path: '/lakebase', getParentRoute: () => rootRouteImport, } as any) -const FilesRouteRoute = FilesRouteRouteImport.update({ - id: '/files', - path: '/files', - getParentRoute: () => rootRouteImport, -} as any) -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) const DataVisualizationRouteRoute = DataVisualizationRouteRouteImport.update({ id: '/data-visualization', path: '/data-visualization', @@ -82,11 +70,7 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute -<<<<<<< HEAD - '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -97,11 +81,7 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute -<<<<<<< HEAD - '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -113,11 +93,7 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute -<<<<<<< HEAD - '/files': typeof FilesRouteRoute '/lakebase': typeof LakebaseRouteRoute -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -130,11 +106,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' -<<<<<<< HEAD - | '/files' | '/lakebase' -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -145,11 +117,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' -<<<<<<< HEAD - | '/files' | '/lakebase' -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -160,11 +128,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' -<<<<<<< HEAD - | '/files' | '/lakebase' -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -176,11 +140,7 @@ export interface RootRouteChildren { AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute -<<<<<<< HEAD - FilesRouteRoute: typeof FilesRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) ReconnectRouteRoute: typeof ReconnectRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute @@ -217,7 +177,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReconnectRouteRouteImport parentRoute: typeof rootRouteImport } -<<<<<<< HEAD '/lakebase': { id: '/lakebase' path: '/lakebase' @@ -225,15 +184,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LakebaseRouteRouteImport parentRoute: typeof rootRouteImport } - '/files': { - id: '/files' - path: '/files' - fullPath: '/files' - preLoaderRoute: typeof FilesRouteRouteImport - parentRoute: typeof rootRouteImport - } -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) '/data-visualization': { id: '/data-visualization' path: '/data-visualization' @@ -270,11 +220,7 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, -<<<<<<< HEAD - FilesRouteRoute: FilesRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, -======= ->>>>>>> aa76021 (chore: remove dev-playground files integration) ReconnectRouteRoute: ReconnectRouteRoute, SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, From 5200066f964276b2a39d7609b9429d1424be7577 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 13:59:50 +0100 Subject: [PATCH 15/23] docs: update --- .../client/src/appKitTypes.d.ts | 71 +----- docs/docs/api/appkit/Class.Plugin.md | 18 ++ .../appkit/Function.contentTypeFromPath.md | 29 +++ docs/docs/api/appkit/index.md | 1 + docs/docs/api/appkit/typedoc-sidebar.ts | 5 + docs/docs/plugins.md | 221 +++++++++++++++--- .../appkit/src/connectors/files/client.ts | 1 - .../appkit/src/connectors/files/defaults.ts | 22 ++ packages/appkit/src/index.ts | 1 + packages/appkit/src/plugins/files/defaults.ts | 3 + packages/appkit/src/plugins/files/plugin.ts | 92 ++++++++ packages/appkit/src/plugins/files/types.ts | 22 +- 12 files changed, 392 insertions(+), 94 deletions(-) create mode 100644 docs/docs/api/appkit/Function.contentTypeFromPath.md diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index 0e0ae0b0..049fae9f 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -14,46 +14,28 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType STRING */ - day_of_week: string; - /** @sqlType DECIMAL(35,2) */ - spend: number; + ; }>; }; apps_list: { name: "apps_list"; parameters: Record; result: Array<{ - /** @sqlType STRING */ - id: string; - /** @sqlType STRING */ - name: string; - /** @sqlType STRING */ - creator: string; - /** @sqlType STRING */ - tags: string; - /** @sqlType DECIMAL(38,6) */ - totalSpend: number; - /** @sqlType DATE */ - createdAt: string; + ; }>; }; cost_recommendations: { name: "cost_recommendations"; parameters: Record; result: Array<{ - /** @sqlType INT */ - dummy: number; + ; }>; }; example: { name: "example"; parameters: Record; result: Array<{ - /** @sqlType BOOLEAN */ - "(1 = 1)": boolean; + ; }>; }; spend_data: { @@ -73,12 +55,7 @@ declare module "@databricks/appkit-ui/react" { creator: SQLStringMarker; }; result: Array<{ - /** @sqlType STRING */ - group_key: string; - /** @sqlType TIMESTAMP */ - aggregation_period: string; - /** @sqlType DECIMAL(38,6) */ - cost_usd: number; + ; }>; }; spend_summary: { @@ -92,12 +69,7 @@ declare module "@databricks/appkit-ui/react" { startDate: SQLDateMarker; }; result: Array<{ - /** @sqlType DECIMAL(33,0) */ - total: number; - /** @sqlType DECIMAL(33,0) */ - average: number; - /** @sqlType DECIMAL(33,0) */ - forecasted: number; + ; }>; }; sql_helpers_test: { @@ -117,22 +89,7 @@ declare module "@databricks/appkit-ui/react" { binaryParam: SQLStringMarker; }; result: Array<{ - /** @sqlType STRING */ - string_value: string; - /** @sqlType STRING */ - number_value: string; - /** @sqlType STRING */ - boolean_value: string; - /** @sqlType STRING */ - date_value: string; - /** @sqlType STRING */ - timestamp_value: string; - /** @sqlType BINARY */ - binary_value: string; - /** @sqlType STRING */ - binary_hex: string; - /** @sqlType INT */ - binary_length: number; + ; }>; }; top_contributors: { @@ -146,10 +103,7 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType DECIMAL(38,6) */ - total_cost_usd: number; + ; }>; }; untagged_apps: { @@ -163,14 +117,7 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType STRING */ - creator: string; - /** @sqlType DECIMAL(38,6) */ - total_cost_usd: number; - /** @sqlType DECIMAL(38,10) */ - avg_period_cost_usd: number; + ; }>; }; } diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 64de5830..5f7bae17 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -345,6 +345,24 @@ BasePlugin.getEndpoints *** +### getSkipBodyParsingPaths() + +```ts +getSkipBodyParsingPaths(): ReadonlySet; +``` + +#### Returns + +`ReadonlySet`\<`string`\> + +#### Implementation of + +```ts +BasePlugin.getSkipBodyParsingPaths +``` + +*** + ### injectRoutes() ```ts diff --git a/docs/docs/api/appkit/Function.contentTypeFromPath.md b/docs/docs/api/appkit/Function.contentTypeFromPath.md new file mode 100644 index 00000000..b146b2b0 --- /dev/null +++ b/docs/docs/api/appkit/Function.contentTypeFromPath.md @@ -0,0 +1,29 @@ +# Function: contentTypeFromPath() + +```ts +function contentTypeFromPath( + filePath: string, + reported?: string, + customTypes?: Record): string; +``` + +Resolve the MIME content type for a file path. + +Resolution order: +1. Custom type map (if the extension matches a key in `customTypes`). +2. Built-in extension map (EXTENSION\_CONTENT\_TYPES). +3. The `reported` type from the server, or `application/octet-stream` as a fallback. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `filePath` | `string` | File path used to extract the extension. | +| `reported?` | `string` | Content type reported by the server (used as fallback). | +| `customTypes?` | `Record`\<`string`, `string`\> | Optional map of extensions to MIME types that takes priority. | + +## Returns + +`string` + +The resolved MIME content type string. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 91f9a66e..3c58b61c 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -65,6 +65,7 @@ plugin architecture, and React integration. | Function | Description | | ------ | ------ | | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | +| [contentTypeFromPath](Function.contentTypeFromPath.md) | Resolve the MIME content type for a file path. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [createLakebasePool](Function.createLakebasePool.md) | Create a Lakebase pool with appkit's logger integration. Telemetry automatically uses appkit's OpenTelemetry configuration via global registry. | | [generateDatabaseCredential](Function.generateDatabaseCredential.md) | Generate OAuth credentials for Postgres database connection using the proper Postgres API. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 3421d7ee..63b513c2 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -200,6 +200,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.appKitTypesPlugin", label: "appKitTypesPlugin" }, + { + type: "doc", + id: "api/appkit/Function.contentTypeFromPath", + label: "contentTypeFromPath" + }, { type: "doc", id: "api/appkit/Function.createApp", diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 3bfe1067..a0a8615f 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -15,6 +15,7 @@ For complete API documentation, see the [`Plugin`](api/appkit/Class.Plugin.md) c Provides HTTP server capabilities with development and production modes. **Key features:** + - Express server for REST APIs - Vite dev server with hot module reload - Static file serving for production @@ -70,10 +71,10 @@ import { createApp, server } from "@databricks/appkit"; await createApp({ plugins: [ server({ - port: 8000, // default: Number(process.env.DATABRICKS_APP_PORT) || 8000 - host: "0.0.0.0", // default: process.env.FLASK_RUN_HOST || "0.0.0.0" - autoStart: true, // default: true - staticPath: "dist", // optional: force a specific static directory + port: 8000, // default: Number(process.env.DATABRICKS_APP_PORT) || 8000 + host: "0.0.0.0", // default: process.env.FLASK_RUN_HOST || "0.0.0.0" + autoStart: true, // default: true + staticPath: "dist", // optional: force a specific static directory }), ], }); @@ -84,6 +85,7 @@ await createApp({ Enables SQL query execution against Databricks SQL Warehouses. **Key features:** + - File-based SQL queries with automatic type generation - Parameterized queries with type-safe [SQL helpers](api/appkit/Variable.sql.md) - JSON and Arrow format support @@ -119,6 +121,7 @@ LIMIT :limit ``` **Supported `-- @param` types** (case-insensitive): + - `STRING`, `NUMERIC`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `BINARY` #### Server-injected parameters @@ -143,6 +146,152 @@ The analytics plugin exposes these endpoints (mounted under `/api/analytics`): - `format: "JSON"` (default) returns JSON rows - `format: "ARROW"` returns an Arrow "statement_id" payload over SSE, then the client fetches binary Arrow from `/api/analytics/arrow-result/:jobId` +### Files plugin + +Provides HTTP routes and a programmatic API for Databricks Unity Catalog volume file operations (list, read, download, upload, delete, preview). + +Routes are mounted at `/api/files/*`. + +#### Configuration + +| Option | Type | Default | Description | +| -------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defaultVolume` | `string` | — | Absolute volume path used to resolve relative file paths (e.g. `"/Volumes/catalog/schema/vol"`). | +| `timeout` | `number` | Per-tier | Operation timeout in milliseconds. Overrides the built-in per-tier defaults (30 s read, 600 s write). | +| `customContentTypes` | `Record` | — | Map of file extensions to MIME types that takes priority over the built-in extension map. Keys should include the leading dot (e.g. `{ ".parquet": "application/vnd.apache.parquet" }`). | + +#### Programmatic API + +After registration, the plugin exposes methods on the app instance via `app.files.()`: + +| Method | Signature | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `list` | `(path?: string) => Promise` | List entries in a directory. Defaults to the configured `defaultVolume` root. | +| `read` | `(path: string) => Promise` | Read a file and return its contents as a UTF-8 string. | +| `download` | `(path: string) => Promise` | Download a file as a readable stream. | +| `exists` | `(path: string) => Promise` | Check whether a file exists. | +| `metadata` | `(path: string) => Promise` | Retrieve metadata (size, content type, last modified) for a file. | +| `upload` | `(path: string, contents: ReadableStream \| Buffer \| string, options?: { overwrite?: boolean }) => Promise` | Upload a file to a Unity Catalog volume. | +| `createDirectory` | `(path: string) => Promise` | Create a directory in a Unity Catalog volume. | +| `delete` | `(path: string) => Promise` | Delete a file or directory from a Unity Catalog volume. | +| `preview` | `(path: string) => Promise` | Get a preview of a file including metadata and a text excerpt. | + +#### HTTP Routes + +All routes are mounted under `/api/files`. File paths are passed via the `path` query parameter. + +| Method | Path | Description | +| ------ | --------------------------- | ---------------------------------------------------- | +| `GET` | `/api/files/root` | Returns the configured `defaultVolume` path. | +| `GET` | `/api/files/list?path=` | List directory contents. | +| `GET` | `/api/files/read?path=` | Read a file as plain text. | +| `GET` | `/api/files/download?path=` | Download a file as an attachment. | +| `GET` | `/api/files/raw?path=` | Serve a file inline with its detected content type. | +| `GET` | `/api/files/exists?path=` | Check whether a file exists (`{ exists: boolean }`). | +| `GET` | `/api/files/metadata?path=` | Retrieve file metadata (size, type, last modified). | +| `GET` | `/api/files/preview?path=` | Get a file preview with text excerpt. | +| `POST` | `/api/files/upload?path=` | Upload a file (stream the request body). | +| `POST` | `/api/files/mkdir` | Create a directory (`{ path }` in body). | +| `POST` | `/api/files/delete?path=` | Delete a file or directory. | + +#### Execution defaults + +Operations use three tiers of execution settings: + +| Tier | Cache | Retry | Timeout | Operations | +| ------------ | -------- | ----------------------- | ------------------- | ------------------------------------- | +| **Read** | 60 s TTL | 3 attempts, 1 s backoff | 30 s | list, read, exists, metadata, preview | +| **Download** | Disabled | 3 attempts, 1 s backoff | 30 s (stream start) | download, raw | +| **Write** | Disabled | Disabled | 600 s | upload, mkdir, delete | + +#### Basic usage + +```ts +import { createApp, files } from "@databricks/appkit"; + +const app = await createApp({ + plugins: [files({ defaultVolume: "/Volumes/catalog/schema/vol" })], +}); +``` + +#### List files in a directory + +```ts +const entries = await app.files.list("/path/to/dir"); +for (const entry of entries) { + console.log(entry.name, entry.is_directory); +} +``` + +#### Read a file as a string + +```ts +const content = await app.files.read("data/config.json"); +const config = JSON.parse(content); +``` + +#### Download a file as a stream + +```ts +const response = await app.files.download("reports/export.csv"); +// response.contents is a ReadableStream +``` + +#### Upload a file + +```ts +await app.files.upload("uploads/report.pdf", fileBuffer, { + overwrite: true, +}); +``` + +#### Check if a file exists + +```ts +const found = await app.files.exists("data/config.json"); +if (!found) { + console.log("File not found"); +} +``` + +#### Get file metadata + +```ts +const meta = await app.files.metadata("data/report.csv"); +console.log(meta.contentLength, meta.contentType, meta.lastModified); +``` + +#### Preview a file + +```ts +const preview = await app.files.preview("data/readme.md"); +// { contentLength, contentType, lastModified, textPreview, isText, isImage } +``` + +#### Custom content types + +```ts +const app = await createApp({ + plugins: [ + files({ + defaultVolume: "/Volumes/catalog/schema/vol", + customContentTypes: { + ".dbx": "application/x-databricks", + ".arrow": "application/vnd.apache.arrow.stream", + }, + }), + ], +}); +``` + +#### User-scoped operations in a route handler + +```ts +// Inside a custom plugin route handler: +const userFiles = this.asUser(req); +const entries = await userFiles.files.list(); +``` + ### Execution context and `asUser(req)` AppKit manages Databricks authentication via two contexts: @@ -196,10 +345,7 @@ Configure plugins when creating your AppKit instance: import { createApp, server, analytics } from "@databricks/appkit"; const AppKit = await createApp({ - plugins: [ - server({ port: 8000 }), - analytics(), - ], + plugins: [server({ port: 8000 }), analytics()], }); ``` @@ -235,12 +381,12 @@ class MyPlugin extends Plugin { permission: "READ", fields: { scope: { env: "MY_SECRET_SCOPE", description: "Secret scope" }, - key: { env: "MY_API_KEY", description: "Secret key name" } - } - } + key: { env: "MY_API_KEY", description: "Secret key name" }, + }, + }, ], - optional: [] - } + optional: [], + }, }; async setup() { @@ -258,15 +404,16 @@ class MyPlugin extends Plugin { exports() { // an object with the methods from this plugin to expose return { - myCustomMethod: this.myCustomMethod - } + myCustomMethod: this.myCustomMethod, + }; } } -export const myPlugin = toPlugin, "myPlugin">( - MyPlugin, - "myPlugin", -); +export const myPlugin = toPlugin< + typeof MyPlugin, + Record, + "myPlugin" +>(MyPlugin, "myPlugin"); ``` ### Config-dependent resources @@ -289,13 +436,30 @@ class MyPlugin extends Plugin { description: "A plugin with optional caching", resources: { required: [ - { type: "sql_warehouse", alias: "warehouse", resourceKey: "sqlWarehouse", description: "Query execution", permission: "CAN_USE", fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } } } + { + type: "sql_warehouse", + alias: "warehouse", + resourceKey: "sqlWarehouse", + description: "Query execution", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, ], optional: [ // Listed as optional in manifest for static analysis - { type: "database", alias: "cache", resourceKey: "cache", description: "Query result caching (if enabled)", permission: "CAN_CONNECT_AND_CREATE", fields: { instance_name: { env: "DATABRICKS_CACHE_INSTANCE" }, database_name: { env: "DATABRICKS_CACHE_DB" } } } - ] - } + { + type: "database", + alias: "cache", + resourceKey: "cache", + description: "Query result caching (if enabled)", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + instance_name: { env: "DATABRICKS_CACHE_INSTANCE" }, + database_name: { env: "DATABRICKS_CACHE_DB" }, + }, + }, + ], + }, }; // Runtime: Convert optional resources to required based on config @@ -313,7 +477,7 @@ class MyPlugin extends Plugin { instance_name: { env: "DATABRICKS_CACHE_INSTANCE" }, database_name: { env: "DATABRICKS_CACHE_DB" }, }, - required: true // Mark as required at runtime + required: true, // Mark as required at runtime }); } return resources; @@ -322,6 +486,7 @@ class MyPlugin extends Plugin { ``` This pattern allows: + - **Static tools** (CLI, docs) to show all possible resources - **Runtime validation** to enforce resources based on actual configuration @@ -341,11 +506,7 @@ To do that, your plugin needs to implement the `exports` method, returning an ob ```ts const AppKit = await createApp({ - plugins: [ - server({ port: 8000 }), - analytics(), - myPlugin(), - ], + plugins: [server({ port: 8000 }), analytics(), myPlugin()], }); AppKit.myPlugin.myCustomMethod(); @@ -364,7 +525,7 @@ await createApp({ plugins: [server(), analytics({})], cache: { enabled: true, - ttl: 3600, // seconds + ttl: 3600, // seconds strictPersistence: false, }, }); diff --git a/packages/appkit/src/connectors/files/client.ts b/packages/appkit/src/connectors/files/client.ts index ed9e4533..883b8d7e 100644 --- a/packages/appkit/src/connectors/files/client.ts +++ b/packages/appkit/src/connectors/files/client.ts @@ -220,7 +220,6 @@ export class FilesConnector { response["content-type"], this.customContentTypes, ), - contentType: contentTypeFromPath(filePath, response["content-type"]), lastModified: response["last-modified"], }; }, diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts index 9685a5fd..889b75ad 100644 --- a/packages/appkit/src/connectors/files/defaults.ts +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -30,12 +30,34 @@ export const EXTENSION_CONTENT_TYPES: Record = Object.freeze({ const TEXT_KEYWORDS = ["json", "xml", "yaml", "sql", "javascript"] as const; +/** + * Determine whether a content type represents text. + * + * Returns `true` for any `text/*` type and for known structured-text types + * such as JSON, XML, YAML, SQL, and JavaScript. + * + * @param contentType - MIME content type string to check. + * @returns `true` if the content type is text-based. + */ export function isTextContentType(contentType: string | undefined): boolean { if (!contentType) return false; if (contentType.startsWith("text/")) return true; return TEXT_KEYWORDS.some((kw) => contentType.includes(kw)); } +/** + * Resolve the MIME content type for a file path. + * + * Resolution order: + * 1. Custom type map (if the extension matches a key in `customTypes`). + * 2. Built-in extension map ({@link EXTENSION_CONTENT_TYPES}). + * 3. The `reported` type from the server, or `application/octet-stream` as a fallback. + * + * @param filePath - File path used to extract the extension. + * @param reported - Content type reported by the server (used as fallback). + * @param customTypes - Optional map of extensions to MIME types that takes priority. + * @returns The resolved MIME content type string. + */ export function contentTypeFromPath( filePath: string, reported?: string, diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 9bb68f0c..67a93cf7 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -74,5 +74,6 @@ export { SpanStatusCode, type TelemetryConfig, } from "./telemetry"; + // Vite plugin and type generation export { appKitTypesPlugin } from "./type-generator/vite-plugin"; diff --git a/packages/appkit/src/plugins/files/defaults.ts b/packages/appkit/src/plugins/files/defaults.ts index 95a620d8..bb26ce01 100644 --- a/packages/appkit/src/plugins/files/defaults.ts +++ b/packages/appkit/src/plugins/files/defaults.ts @@ -1,5 +1,6 @@ import type { PluginExecuteConfig } from "shared"; +/** Execution defaults for read-tier operations (list, read, exists, metadata, preview). Cache 60 s, retry 3x with 1 s backoff, 30 s timeout. */ export const filesReadDefaults: PluginExecuteConfig = { cache: { enabled: true, @@ -13,6 +14,7 @@ export const filesReadDefaults: PluginExecuteConfig = { timeout: 30_000, }; +/** Execution defaults for download-tier operations (download, raw). No cache, retry 3x with 1 s backoff, 30 s timeout (stream start only). */ export const filesDownloadDefaults: PluginExecuteConfig = { cache: { enabled: false, @@ -28,6 +30,7 @@ export const filesDownloadDefaults: PluginExecuteConfig = { timeout: 30_000, }; +/** Execution defaults for write-tier operations (upload, mkdir, delete). No cache, no retry, 600 s timeout. */ export const filesWriteDefaults: PluginExecuteConfig = { cache: { enabled: false, diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 68efeeec..52a3c433 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -18,6 +18,7 @@ const logger = createLogger("files"); export class FilesPlugin extends Plugin { name = "files"; + /** Plugin manifest declaring metadata and resource requirements. */ static manifest = filesManifest; protected static description = "Files plugin for Databricks file operations"; protected declare config: IFilesConfig; @@ -35,26 +36,64 @@ export class FilesPlugin extends Plugin { }); } + /** + * List entries in a directory. + * + * @param directoryPath - Absolute or relative path. Defaults to the configured `defaultVolume` root. + * @returns Array of directory entries. + */ async list(directoryPath?: string) { return this.filesConnector.list(getWorkspaceClient(), directoryPath); } + /** + * Read a file and return its contents as a string. + * + * @param filePath - Absolute or relative path to the file. + * @returns The file contents as a UTF-8 string. + */ async read(filePath: string) { return this.filesConnector.read(getWorkspaceClient(), filePath); } + /** + * Download a file as a readable stream. + * + * @param filePath - Absolute or relative path to the file. + * @returns A response containing a readable stream of the file contents. + */ async download(filePath: string): Promise { return this.filesConnector.download(getWorkspaceClient(), filePath); } + /** + * Check whether a file exists. + * + * @param filePath - Absolute or relative path to the file. + * @returns `true` if the file exists, `false` otherwise. + */ async exists(filePath: string) { return this.filesConnector.exists(getWorkspaceClient(), filePath); } + /** + * Retrieve metadata (size, content type, last modified) for a file. + * + * @param filePath - Absolute or relative path to the file. + * @returns File metadata including content length, type, and last modified date. + */ async metadata(filePath: string) { return this.filesConnector.metadata(getWorkspaceClient(), filePath); } + /** + * Upload a file to a Unity Catalog volume. + * + * @param filePath - Absolute or relative destination path. + * @param contents - File body as a readable stream, Buffer, or string. + * @param options - Upload options. + * @param options.overwrite - When `true`, overwrite an existing file at the same path. + */ async upload( filePath: string, contents: ReadableStream | Buffer | string, @@ -68,6 +107,11 @@ export class FilesPlugin extends Plugin { ); } + /** + * Create a directory in a Unity Catalog volume. + * + * @param directoryPath - Absolute or relative path for the new directory. + */ async createDirectory(directoryPath: string) { return this.filesConnector.createDirectory( getWorkspaceClient(), @@ -75,10 +119,21 @@ export class FilesPlugin extends Plugin { ); } + /** + * Delete a file or directory from a Unity Catalog volume. + * + * @param filePath - Absolute or relative path to the file or directory. + */ async delete(filePath: string) { return this.filesConnector.delete(getWorkspaceClient(), filePath); } + /** + * Get a preview of a file including metadata and a text excerpt. + * + * @param filePath - Absolute or relative path to the file. + * @returns Preview with metadata, text content hint, and format flags. + */ async preview(filePath: string) { return this.filesConnector.preview(getWorkspaceClient(), filePath); } @@ -517,21 +572,58 @@ export class FilesPlugin extends Plugin { this.streamManager.abortAll(); } + /** + * Returns the programmatic API for the Files plugin. + * Note: `asUser()` is automatically added by AppKit. + */ exports() { return { + /** List entries in a directory. */ list: this.list, + /** Read a file as a string. */ read: this.read, + /** Download a file as a readable stream. */ download: this.download, + /** Check whether a file exists. */ exists: this.exists, + /** Retrieve file metadata. */ metadata: this.metadata, + /** Upload a file. */ upload: this.upload, + /** Create a directory. */ createDirectory: this.createDirectory, + /** Delete a file or directory. */ delete: this.delete, + /** Get a file preview with text excerpt. */ preview: this.preview, }; } } +/** + * + */ +/** + * Files plugin for Databricks Unity Catalog volume operations. + * + * Provides HTTP routes and a programmatic API for listing, reading, + * downloading, uploading, deleting, and previewing files with built-in + * caching, retry, and timeout handling via the execution interceptor pipeline. + * + * Routes are mounted at `/api/files/*`. + * + * @example + * ```typescript + * import { createApp, files } from "@databricks/appkit"; + * + * const app = await createApp({ + * plugins: [ + * files({ defaultVolume: "/Volumes/catalog/schema/vol" }), + * ], + * }); + * ``` + */ + /** * @internal */ diff --git a/packages/appkit/src/plugins/files/types.ts b/packages/appkit/src/plugins/files/types.ts index 578b7aee..4a0baa75 100644 --- a/packages/appkit/src/plugins/files/types.ts +++ b/packages/appkit/src/plugins/files/types.ts @@ -1,24 +1,44 @@ import type { files } from "@databricks/sdk-experimental"; import type { BasePluginConfig } from "shared"; +/** + * Configuration for the Files plugin. + */ export interface IFilesConfig extends BasePluginConfig { + /** Operation timeout in milliseconds. Overrides the per-tier defaults. */ timeout?: number; + /** Absolute volume path used to resolve relative file paths (e.g. `"/Volumes/catalog/schema/vol"`). */ defaultVolume?: string; + /** Map of file extensions to MIME types that takes priority over the built-in extension map. */ customContentTypes?: Record; } -// TODO: Add request/response types for file operations +/** A single entry returned when listing a directory. Re-exported from `@databricks/sdk-experimental`. */ export type DirectoryEntry = files.DirectoryEntry; + +/** Response object for file downloads containing a readable stream. Re-exported from `@databricks/sdk-experimental`. */ export type DownloadResponse = files.DownloadResponse; +/** + * Metadata for a file stored in a Unity Catalog volume. + */ export interface FileMetadata { + /** File size in bytes. */ contentLength: number | undefined; + /** MIME content type of the file. */ contentType: string | undefined; + /** ISO 8601 timestamp of the last modification. */ lastModified: string | undefined; } +/** + * Preview information for a file, extending {@link FileMetadata} with content hints. + */ export interface FilePreview extends FileMetadata { + /** First portion of text content, or `null` for non-text files. */ textPreview: string | null; + /** Whether the file is detected as a text format. */ isText: boolean; + /** Whether the file is detected as an image format. */ isImage: boolean; } From a0dda7524e76ce698eb056ca433a96033af17b3b Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 16:10:19 +0100 Subject: [PATCH 16/23] chore: address code-review, improve consistency across endpoints, fix docs, remove redundancy --- docs/docs/plugins.md | 4 +- .../appkit/src/connectors/files/client.ts | 15 +- .../src/connectors/files/tests/client.test.ts | 22 ++- packages/appkit/src/plugins/files/README.md | 145 ------------------ packages/appkit/src/plugins/files/plugin.ts | 28 ++-- 5 files changed, 48 insertions(+), 166 deletions(-) delete mode 100644 packages/appkit/src/plugins/files/README.md diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index a0a8615f..310f16d6 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -192,7 +192,7 @@ All routes are mounted under `/api/files`. File paths are passed via the `path` | `GET` | `/api/files/preview?path=` | Get a file preview with text excerpt. | | `POST` | `/api/files/upload?path=` | Upload a file (stream the request body). | | `POST` | `/api/files/mkdir` | Create a directory (`{ path }` in body). | -| `POST` | `/api/files/delete?path=` | Delete a file or directory. | +| `POST` | `/api/files/delete` | Delete a file or directory (`{ path }` in body). | #### Execution defaults @@ -289,7 +289,7 @@ const app = await createApp({ ```ts // Inside a custom plugin route handler: const userFiles = this.asUser(req); -const entries = await userFiles.files.list(); +const entries = await userFiles.list(); ``` ### Execution context and `asUser(req)` diff --git a/packages/appkit/src/connectors/files/client.ts b/packages/appkit/src/connectors/files/client.ts index 883b8d7e..72631e10 100644 --- a/packages/appkit/src/connectors/files/client.ts +++ b/packages/appkit/src/connectors/files/client.ts @@ -59,7 +59,10 @@ export class FilesConnector { }; } - private resolvePath(filePath: string): string { + resolvePath(filePath: string): string { + if (filePath.includes("..")) { + throw new Error('Path traversal ("../") is not allowed.'); + } if (filePath.startsWith("/")) { return filePath; } @@ -300,7 +303,7 @@ export class FilesConnector { async preview( client: WorkspaceClient, filePath: string, - options?: { maxBytes?: number }, + options?: { maxChars?: number }, ): Promise { return this.traced( "preview", @@ -324,9 +327,9 @@ export class FilesConnector { const reader = response.contents.getReader(); const decoder = new TextDecoder(); let preview = ""; - const maxBytes = options?.maxBytes ?? 1024; + const maxChars = options?.maxChars ?? 1024; - while (preview.length < maxBytes) { + while (preview.length < maxChars) { const { done, value } = await reader.read(); if (done) break; preview += decoder.decode(value, { stream: true }); @@ -334,8 +337,8 @@ export class FilesConnector { preview += decoder.decode(); await reader.cancel(); - if (preview.length > maxBytes) { - preview = preview.slice(0, maxBytes); + if (preview.length > maxChars) { + preview = preview.slice(0, maxChars); } return { ...meta, textPreview: preview, isText: true, isImage: false }; diff --git a/packages/appkit/src/connectors/files/tests/client.test.ts b/packages/appkit/src/connectors/files/tests/client.test.ts index 1beff015..542e3104 100644 --- a/packages/appkit/src/connectors/files/tests/client.test.ts +++ b/packages/appkit/src/connectors/files/tests/client.test.ts @@ -144,6 +144,26 @@ describe("FilesConnector", () => { }); }); + test("paths containing '..' are rejected", async () => { + const connector = new FilesConnector({ + defaultVolume: "/Volumes/catalog/schema/vol", + }); + + await expect( + connector.download(mockClient, "../../../etc/passwd"), + ).rejects.toThrow('Path traversal ("../") is not allowed.'); + }); + + test("absolute paths containing '..' are rejected", async () => { + const connector = new FilesConnector({ + defaultVolume: "/Volumes/catalog/schema/vol", + }); + + await expect( + connector.download(mockClient, "/Volumes/catalog/../other/file.txt"), + ).rejects.toThrow('Path traversal ("../") is not allowed.'); + }); + test("constructor without defaultVolume omits it", async () => { const connector = new FilesConnector({}); @@ -634,7 +654,7 @@ describe("FilesConnector", () => { }); }); - test("text files return truncated preview (max 1024 bytes)", async () => { + test("text files return truncated preview (max 1024 chars)", async () => { const longText = "A".repeat(2000); mockFilesApi.getMetadata.mockResolvedValue({ diff --git a/packages/appkit/src/plugins/files/README.md b/packages/appkit/src/plugins/files/README.md deleted file mode 100644 index 13830d6d..00000000 --- a/packages/appkit/src/plugins/files/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Files Plugin - -The files plugin provides HTTP routes and a programmatic API for Databricks Unity Catalog volume file operations. It supports listing, reading, downloading, uploading, deleting, and previewing files with built-in caching, retry, and timeout handling via the execution interceptor pipeline. - -Routes are automatically registered via `injectRoutes` and mounted at `/api/files/*`. - -## Configuration - -The plugin accepts an `IFilesConfig` object: - -```ts -interface IFilesConfig { - timeout?: number; // Operation timeout in ms - defaultVolume?: string; // Absolute volume path, e.g. "/Volumes/catalog/schema/vol" -} -``` - -Usage with the `files()` factory: - -```ts -import { files } from "@databricks/appkit"; - -files({ defaultVolume: "/Volumes/catalog/schema/vol" }); -``` - -## Routes - -All routes (except `/root`) execute in user context via `asUser(req)`. - -| Method | Path | Query/Body Params | Response | `exports()` method | -| ------ | ----------- | ---------------------------- | ------------------------------------------------- | ------------------- | -| GET | `/root` | - | `{ root: string \| null }` | - | -| GET | `/list` | `?path` (optional) | `DirectoryEntry[]` | `list()` | -| GET | `/read` | `?path` (required) | `text/plain` body | `read()` | -| GET | `/download` | `?path` (required) | Binary stream (`Content-Disposition: attachment`) | `download()` | -| GET | `/raw` | `?path` (required) | Binary stream (inline, no Content-Disposition) | `download()` (inline) | -| GET | `/exists` | `?path` (required) | `{ exists: boolean }` | `exists()` | -| GET | `/metadata` | `?path` (required) | `FileMetadata` | `metadata()` | -| GET | `/preview` | `?path` (required) | `FilePreview` | `preview()` | -| POST | `/upload` | `?path` (required), raw body | `{ success: true }` | `upload()` | -| POST | `/mkdir` | `body.path` (required) | `{ success: true }` | `createDirectory()` | -| POST | `/delete` | `?path` (required) | `{ success: true }` | `delete()` | - -## Execution Defaults - -Every operation runs through the interceptor pipeline with tier-specific defaults: - -| Tier | Cache | Retry | Timeout | Operations | -| ---------- | ------- | ----- | ------- | ---------------------------------------- | -| **Read** | 60 s | 3× | 30 s | list, read, exists, metadata, preview | -| **Download** | none | 3× | 30 s | download, raw | -| **Write** | none | none | 600 s | upload, mkdir, delete | - -Retry uses exponential backoff with a 1 s initial delay. - -## Cache Invalidation - -Write operations (`upload`, `mkdir`, `delete`) automatically invalidate the cached `list` entry for the parent directory so subsequent listings reflect the change. - -## Types - -```ts -// Plugin configuration -interface IFilesConfig { - timeout?: number; - defaultVolume?: string; -} - -// Re-exported from @databricks/sdk-experimental -type DirectoryEntry = files.DirectoryEntry; -type DownloadResponse = files.DownloadResponse; - -// File metadata returned by /metadata -interface FileMetadata { - contentLength: number | undefined; - contentType: string | undefined; - lastModified: string | undefined; -} - -// File preview returned by /preview (extends FileMetadata) -interface FilePreview extends FileMetadata { - textPreview: string | null; // First 1 KB of text content, or null for non-text - isText: boolean; - isImage: boolean; -} -``` - -## `exports()` API - -The programmatic API returned by `exports()` for server-side use: - -| Method | Signature | Returns | -| ------------------- | ---------------------------------------------------------------------- | ------------------------ | -| `list` | `(directoryPath?: string)` | `DirectoryEntry[]` | -| `read` | `(filePath: string)` | `string` | -| `download` | `(filePath: string)` | `DownloadResponse` | -| `exists` | `(filePath: string)` | `boolean` | -| `metadata` | `(filePath: string)` | `FileMetadata` | -| `upload` | `(filePath: string, contents: ReadableStream \| Buffer \| string, options?: { overwrite?: boolean })` | `void` | -| `createDirectory` | `(directoryPath: string)` | `void` | -| `delete` | `(filePath: string)` | `void` | -| `preview` | `(filePath: string, options?: { maxBytes?: number })` | `FilePreview` | - -## Path Resolution - -Paths can be **absolute** or **relative**: - -- **Absolute** — starts with `/`, used as-is (e.g. `/Volumes/catalog/schema/vol/data.csv`) -- **Relative** — prepended with `defaultVolume` (e.g. `data.csv` → `/Volumes/catalog/schema/vol/data.csv`) - -If a relative path is used and no `defaultVolume` is configured, an error is thrown. - -The `list()` method with no arguments lists the `defaultVolume` root. - -## Content-Type Resolution - -`contentTypeFromPath(filePath, reported?)` resolves a file's content type: - -1. If the server reports a content type other than `application/octet-stream`, use it. -2. Otherwise, match the file extension against a built-in map. -3. Fall back to the reported type or `application/octet-stream`. - -Supported extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.bmp`, `.ico`, `.json`, `.xml`, `.html`, `.css`, `.js`, `.txt`, `.md`, `.csv`, `.pdf`. - -## Error Responses - -All errors return JSON with the shape: - -```json -{ - "error": "Human-readable message", - "plugin": "files" -} -``` - -## HTTP Status Codes - -| Status | Description | -| ------ | --------------------------------------- | -| 400 | Missing required `path` parameter | -| 500 | Operation failed (SDK or network error) | - -## User Context - -Routes use `this.asUser(req)` which wraps the plugin's `getFilesClient()` so that the underlying `getWorkspaceClient()` returns a client scoped to the requesting user's Databricks credentials (on-behalf-of / OBO). The `/root` route is the only exception since it only reads plugin config. diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 52a3c433..7fc0eef0 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -251,6 +251,10 @@ export class FilesPlugin extends Plugin { }; } + private _resolvePath(path: string): string { + return this.filesConnector.resolvePath(path); + } + /** * Invalidate cached list entries for a directory after a write operation. */ @@ -272,7 +276,10 @@ export class FilesPlugin extends Plugin { const result = await executor.execute( async () => executor.list(path), - this._readSettings(["files:list", path ?? "__root__"]), + this._readSettings([ + "files:list", + path ? this._resolvePath(path) : "__root__", + ]), ); if (result === undefined) { @@ -295,7 +302,7 @@ export class FilesPlugin extends Plugin { const executor = this.asUser(req); const result = await executor.execute( async () => executor.read(path), - this._readSettings(["files:read", path]), + this._readSettings(["files:read", this._resolvePath(path)]), ); if (result === undefined) { @@ -396,7 +403,7 @@ export class FilesPlugin extends Plugin { const executor = this.asUser(req); const result = await executor.execute( async () => executor.exists(path), - this._readSettings(["files:exists", path]), + this._readSettings(["files:exists", this._resolvePath(path)]), ); if (result === undefined) { @@ -419,7 +426,7 @@ export class FilesPlugin extends Plugin { const executor = this.asUser(req); const result = await executor.execute( async () => executor.metadata(path), - this._readSettings(["files:metadata", path]), + this._readSettings(["files:metadata", this._resolvePath(path)]), ); if (result === undefined) { @@ -444,7 +451,7 @@ export class FilesPlugin extends Plugin { const executor = this.asUser(req); const result = await executor.execute( async () => executor.preview(path), - this._readSettings(["files:preview", path]), + this._readSettings(["files:preview", this._resolvePath(path)]), ); if (result === undefined) { @@ -500,7 +507,7 @@ export class FilesPlugin extends Plugin { } const parentDir = path.substring(0, path.lastIndexOf("/")) || path; - this._invalidateListCache(parentDir); + this._invalidateListCache(this._resolvePath(parentDir)); logger.debug(req, "Upload complete: path=%s", path); res.json(result); @@ -533,7 +540,7 @@ export class FilesPlugin extends Plugin { } const parentDir = dirPath.substring(0, dirPath.lastIndexOf("/")) || dirPath; - this._invalidateListCache(parentDir); + this._invalidateListCache(this._resolvePath(parentDir)); res.json(result); } @@ -542,7 +549,7 @@ export class FilesPlugin extends Plugin { req: express.Request, res: express.Response, ): Promise { - const path = req.query.path as string; + const path = req.body?.path as string; if (!path) { res.status(400).json({ error: "path is required", plugin: this.name }); return; @@ -563,7 +570,7 @@ export class FilesPlugin extends Plugin { } const parentDir = path.substring(0, path.lastIndexOf("/")) || path; - this._invalidateListCache(parentDir); + this._invalidateListCache(this._resolvePath(parentDir)); res.json(result); } @@ -600,9 +607,6 @@ export class FilesPlugin extends Plugin { } } -/** - * - */ /** * Files plugin for Databricks Unity Catalog volume operations. * From b95711f1a681a6d803665740b6417b2e00e00314 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 16:25:24 +0100 Subject: [PATCH 17/23] fix: xss vulnerability on raw serving files --- .../appkit/src/connectors/files/defaults.ts | 30 +++++ .../connectors/files/tests/defaults.test.ts | 54 +++++++++ packages/appkit/src/plugins/files/plugin.ts | 27 ++++- .../files/tests/plugin.integration.test.ts | 110 ++++++++++++++++++ 4 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 packages/appkit/src/connectors/files/tests/defaults.test.ts diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts index 889b75ad..2c9e54de 100644 --- a/packages/appkit/src/connectors/files/defaults.ts +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -58,6 +58,36 @@ export function isTextContentType(contentType: string | undefined): boolean { * @param customTypes - Optional map of extensions to MIME types that takes priority. * @returns The resolved MIME content type string. */ +/** + * MIME types that are safe to serve inline (i.e. browsers cannot execute + * scripts from them). Any type **not** in this set should be forced to + * download via `Content-Disposition: attachment` when served by the `/raw` + * endpoint to prevent stored-XSS attacks. + */ +export const SAFE_INLINE_CONTENT_TYPES: ReadonlySet = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/vnd.microsoft.icon", + "text/plain", + "text/csv", + "text/markdown", + "application/json", + "application/pdf", +]); + +/** + * Check whether a content type is safe to serve inline. + * + * @param contentType - MIME content type string. + * @returns `true` if the type is in the safe-inline allowlist. + */ +export function isSafeInlineContentType(contentType: string): boolean { + return SAFE_INLINE_CONTENT_TYPES.has(contentType); +} + export function contentTypeFromPath( filePath: string, reported?: string, diff --git a/packages/appkit/src/connectors/files/tests/defaults.test.ts b/packages/appkit/src/connectors/files/tests/defaults.test.ts new file mode 100644 index 00000000..70d09dbf --- /dev/null +++ b/packages/appkit/src/connectors/files/tests/defaults.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { + isSafeInlineContentType, + SAFE_INLINE_CONTENT_TYPES, +} from "../defaults"; + +describe("isSafeInlineContentType", () => { + const safeTypes = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/vnd.microsoft.icon", + "text/plain", + "text/csv", + "text/markdown", + "application/json", + "application/pdf", + ]; + + for (const type of safeTypes) { + test(`returns true for safe type: ${type}`, () => { + expect(isSafeInlineContentType(type)).toBe(true); + }); + } + + const dangerousTypes = [ + "text/html", + "text/javascript", + "image/svg+xml", + "text/css", + "application/xml", + ]; + + for (const type of dangerousTypes) { + test(`returns false for dangerous type: ${type}`, () => { + expect(isSafeInlineContentType(type)).toBe(false); + }); + } + + test("returns false for unknown types", () => { + expect(isSafeInlineContentType("application/octet-stream")).toBe(false); + expect(isSafeInlineContentType("application/x-yaml")).toBe(false); + expect(isSafeInlineContentType("video/mp4")).toBe(false); + }); + + test("SAFE_INLINE_CONTENT_TYPES is frozen (ReadonlySet)", () => { + expect(SAFE_INLINE_CONTENT_TYPES.size).toBe(safeTypes.length); + for (const type of safeTypes) { + expect(SAFE_INLINE_CONTENT_TYPES.has(type)).toBe(true); + } + }); +}); diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 7fc0eef0..01622b1b 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -1,7 +1,11 @@ import { Readable } from "node:stream"; import type express from "express"; import type { IAppRouter, PluginExecutionSettings } from "shared"; -import { contentTypeFromPath, FilesConnector } from "../../connectors/files"; +import { + contentTypeFromPath, + FilesConnector, + isSafeInlineContentType, +} from "../../connectors/files"; import { getCurrentUserId, getWorkspaceClient } from "../../context"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; @@ -342,6 +346,7 @@ export class FilesPlugin extends Plugin { "Content-Type", contentTypeFromPath(path, undefined, this.config.customContentTypes), ); + res.setHeader("X-Content-Type-Options", "nosniff"); if (response.contents) { const nodeStream = Readable.fromWeb( response.contents as import("node:stream/web").ReadableStream, @@ -376,10 +381,24 @@ export class FilesPlugin extends Plugin { return; } - res.setHeader( - "Content-Type", - contentTypeFromPath(path, undefined, this.config.customContentTypes), + const resolvedType = contentTypeFromPath( + path, + undefined, + this.config.customContentTypes, ); + + res.setHeader("Content-Type", resolvedType); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "sandbox"); + + if (!isSafeInlineContentType(resolvedType)) { + const fileName = path.split("/").pop() ?? "download"; + res.setHeader( + "Content-Disposition", + `attachment; filename="${fileName}"`, + ); + } + if (response.contents) { const nodeStream = Readable.fromWeb( response.contents as import("node:stream/web").ReadableStream, diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index 8bed6db4..65583c8a 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -301,6 +301,116 @@ describe("Files Plugin Integration", () => { }); }); + describe("Raw Endpoint Security Headers", () => { + test("safe type (image/png) sets security headers without Content-Disposition", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("PNG data"), + }); + + const response = await fetch( + `${baseUrl}/api/files/raw?path=/Volumes/catalog/schema/vol/image.png`, + { headers: authHeaders, redirect: "manual" }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("content-disposition")).toBeNull(); + }); + + test("dangerous type (text/html) forces download via Content-Disposition", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(""), + }); + + const response = await fetch( + `${baseUrl}/api/files/raw?path=/Volumes/catalog/schema/vol/malicious.html`, + { headers: authHeaders, redirect: "manual" }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="malicious.html"', + ); + }); + + test("SVG (image/svg+xml) is treated as dangerous", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString(""), + }); + + const response = await fetch( + `${baseUrl}/api/files/raw?path=/Volumes/catalog/schema/vol/icon.svg`, + { headers: authHeaders, redirect: "manual" }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/svg+xml"); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="icon.svg"', + ); + }); + + test("JavaScript (text/javascript) is treated as dangerous", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("alert('xss')"), + }); + + const response = await fetch( + `${baseUrl}/api/files/raw?path=/Volumes/catalog/schema/vol/script.js`, + { headers: authHeaders, redirect: "manual" }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/javascript"); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="script.js"', + ); + }); + + test("safe type (application/json) is served inline", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString('{"key":"value"}'), + }); + + const response = await fetch( + `${baseUrl}/api/files/raw?path=/Volumes/catalog/schema/vol/data.json`, + { headers: authHeaders, redirect: "manual" }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("content-disposition")).toBeNull(); + }); + }); + + describe("Download Endpoint Security Headers", () => { + test("sets X-Content-Type-Options: nosniff", async () => { + mockFilesApi.download.mockResolvedValue({ + contents: streamFromString("file data"), + }); + + const response = await fetch( + `${baseUrl}/api/files/download?path=/Volumes/catalog/schema/vol/file.txt`, + { headers: authHeaders, redirect: "manual" }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="file.txt"', + ); + }); + }); + describe("Error Handling", () => { test("SDK exceptions return 500 with generic error", async () => { mockFilesApi.getMetadata.mockRejectedValue( From 1c5805bb9f2a32de2637bb0f7c9201d313ada206 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 16:29:36 +0100 Subject: [PATCH 18/23] chore: linter warnings --- .../src/connectors/files/tests/client.test.ts | 2 +- packages/appkit/src/connectors/index.ts | 2 +- .../src/plugins/files/tests/plugin.test.ts | 80 +++++++++---------- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/appkit/src/connectors/files/tests/client.test.ts b/packages/appkit/src/connectors/files/tests/client.test.ts index 542e3104..96e4693a 100644 --- a/packages/appkit/src/connectors/files/tests/client.test.ts +++ b/packages/appkit/src/connectors/files/tests/client.test.ts @@ -671,7 +671,7 @@ describe("FilesConnector", () => { expect(result.isText).toBe(true); expect(result.isImage).toBe(false); expect(result.textPreview).not.toBeNull(); - expect(result.textPreview!.length).toBeLessThanOrEqual(1024); + expect(result.textPreview?.length).toBeLessThanOrEqual(1024); }); test("text/html files are treated as text", async () => { diff --git a/packages/appkit/src/connectors/index.ts b/packages/appkit/src/connectors/index.ts index c20c794a..19181323 100644 --- a/packages/appkit/src/connectors/index.ts +++ b/packages/appkit/src/connectors/index.ts @@ -1,4 +1,4 @@ -export * from "./lakebase"; export * from "./files"; +export * from "./lakebase"; export * from "./lakebase-v1"; export * from "./sql-warehouse"; diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index 13df9b92..9ea52118 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -2,48 +2,46 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../../context/service-context"; import { FilesPlugin, files } from "../plugin"; -import { streamFromString } from "./utils"; - -const { mockFilesApi, mockClient, MockApiError, mockCacheInstance } = - vi.hoisted(() => { - const mockFilesApi = { - listDirectoryContents: vi.fn(), - download: vi.fn(), - getMetadata: vi.fn(), - upload: vi.fn(), - createDirectory: vi.fn(), - delete: vi.fn(), - }; - - const mockClient = { - files: mockFilesApi, - config: { - host: "https://test.databricks.com", - authenticate: vi.fn(), - }, - }; - - class MockApiError extends Error { - statusCode: number; - constructor(message: string, statusCode: number) { - super(message); - this.name = "ApiError"; - this.statusCode = statusCode; - } - } - - const mockCacheInstance = { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise) => - fn(), - ), - generateKey: vi.fn(), - }; - return { mockFilesApi, mockClient, MockApiError, mockCacheInstance }; - }); +const { mockClient, MockApiError, mockCacheInstance } = vi.hoisted(() => { + const mockFilesApi = { + listDirectoryContents: vi.fn(), + download: vi.fn(), + getMetadata: vi.fn(), + upload: vi.fn(), + createDirectory: vi.fn(), + delete: vi.fn(), + }; + + const mockClient = { + files: mockFilesApi, + config: { + host: "https://test.databricks.com", + authenticate: vi.fn(), + }, + }; + + class MockApiError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + } + } + + const mockCacheInstance = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise) => + fn(), + ), + generateKey: vi.fn(), + }; + + return { mockFilesApi, mockClient, MockApiError, mockCacheInstance }; +}); vi.mock("@databricks/sdk-experimental", () => ({ WorkspaceClient: vi.fn(() => mockClient), From e5e973d557055ae0b9f76d82c12c5c45f577c9f3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 16:43:10 +0100 Subject: [PATCH 19/23] fix: docs generation --- docs/docs/api/appkit/Function.contentTypeFromPath.md | 11 +++-------- packages/appkit/src/connectors/files/defaults.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/docs/api/appkit/Function.contentTypeFromPath.md b/docs/docs/api/appkit/Function.contentTypeFromPath.md index b146b2b0..540a344a 100644 --- a/docs/docs/api/appkit/Function.contentTypeFromPath.md +++ b/docs/docs/api/appkit/Function.contentTypeFromPath.md @@ -9,18 +9,13 @@ function contentTypeFromPath( Resolve the MIME content type for a file path. -Resolution order: -1. Custom type map (if the extension matches a key in `customTypes`). -2. Built-in extension map (EXTENSION\_CONTENT\_TYPES). -3. The `reported` type from the server, or `application/octet-stream` as a fallback. - ## Parameters | Parameter | Type | Description | | ------ | ------ | ------ | -| `filePath` | `string` | File path used to extract the extension. | -| `reported?` | `string` | Content type reported by the server (used as fallback). | -| `customTypes?` | `Record`\<`string`, `string`\> | Optional map of extensions to MIME types that takes priority. | +| `filePath` | `string` | Path to the file (only the extension is inspected). | +| `reported?` | `string` | Optional MIME type reported by the caller; used as fallback when the extension is unknown. | +| `customTypes?` | `Record`\<`string`, `string`\> | Optional map of extension → MIME type overrides (e.g. `{ ".csv": "text/csv" }`). | ## Returns diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts index 2c9e54de..96b6350b 100644 --- a/packages/appkit/src/connectors/files/defaults.ts +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -88,6 +88,14 @@ export function isSafeInlineContentType(contentType: string): boolean { return SAFE_INLINE_CONTENT_TYPES.has(contentType); } +/** + * Resolve the MIME content type for a file path. + * + * @param filePath - Path to the file (only the extension is inspected). + * @param reported - Optional MIME type reported by the caller; used as fallback when the extension is unknown. + * @param customTypes - Optional map of extension → MIME type overrides (e.g. `{ ".csv": "text/csv" }`). + * @returns The resolved MIME content type string. + */ export function contentTypeFromPath( filePath: string, reported?: string, From bb5722671b0f206699400e71a167adb782496878 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 20 Feb 2026 16:57:26 +0100 Subject: [PATCH 20/23] chore: security in-depth and a few inconsistencies between docs and manifest --- .../client/src/appKitTypes.d.ts | 71 ++++++++++++++++--- packages/appkit/src/plugins/files/helpers.ts | 27 +++++++ .../appkit/src/plugins/files/manifest.json | 2 +- packages/appkit/src/plugins/files/plugin.ts | 14 ++-- .../src/plugins/files/tests/helpers.test.ts | 64 ++++++++++++++++- .../files/tests/plugin.integration.test.ts | 4 +- 6 files changed, 161 insertions(+), 21 deletions(-) diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index 049fae9f..0e0ae0b0 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -14,28 +14,46 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + app_name: string; + /** @sqlType STRING */ + day_of_week: string; + /** @sqlType DECIMAL(35,2) */ + spend: number; }>; }; apps_list: { name: "apps_list"; parameters: Record; result: Array<{ - ; + /** @sqlType STRING */ + id: string; + /** @sqlType STRING */ + name: string; + /** @sqlType STRING */ + creator: string; + /** @sqlType STRING */ + tags: string; + /** @sqlType DECIMAL(38,6) */ + totalSpend: number; + /** @sqlType DATE */ + createdAt: string; }>; }; cost_recommendations: { name: "cost_recommendations"; parameters: Record; result: Array<{ - ; + /** @sqlType INT */ + dummy: number; }>; }; example: { name: "example"; parameters: Record; result: Array<{ - ; + /** @sqlType BOOLEAN */ + "(1 = 1)": boolean; }>; }; spend_data: { @@ -55,7 +73,12 @@ declare module "@databricks/appkit-ui/react" { creator: SQLStringMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + group_key: string; + /** @sqlType TIMESTAMP */ + aggregation_period: string; + /** @sqlType DECIMAL(38,6) */ + cost_usd: number; }>; }; spend_summary: { @@ -69,7 +92,12 @@ declare module "@databricks/appkit-ui/react" { startDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType DECIMAL(33,0) */ + total: number; + /** @sqlType DECIMAL(33,0) */ + average: number; + /** @sqlType DECIMAL(33,0) */ + forecasted: number; }>; }; sql_helpers_test: { @@ -89,7 +117,22 @@ declare module "@databricks/appkit-ui/react" { binaryParam: SQLStringMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + string_value: string; + /** @sqlType STRING */ + number_value: string; + /** @sqlType STRING */ + boolean_value: string; + /** @sqlType STRING */ + date_value: string; + /** @sqlType STRING */ + timestamp_value: string; + /** @sqlType BINARY */ + binary_value: string; + /** @sqlType STRING */ + binary_hex: string; + /** @sqlType INT */ + binary_length: number; }>; }; top_contributors: { @@ -103,7 +146,10 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + app_name: string; + /** @sqlType DECIMAL(38,6) */ + total_cost_usd: number; }>; }; untagged_apps: { @@ -117,7 +163,14 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - ; + /** @sqlType STRING */ + app_name: string; + /** @sqlType STRING */ + creator: string; + /** @sqlType DECIMAL(38,6) */ + total_cost_usd: number; + /** @sqlType DECIMAL(38,10) */ + avg_period_cost_usd: number; }>; }; } diff --git a/packages/appkit/src/plugins/files/helpers.ts b/packages/appkit/src/plugins/files/helpers.ts index 599368bf..e30115a8 100644 --- a/packages/appkit/src/plugins/files/helpers.ts +++ b/packages/appkit/src/plugins/files/helpers.ts @@ -2,3 +2,30 @@ export { contentTypeFromPath, isTextContentType, } from "../../connectors/files/defaults"; + +/** + * Extract the parent directory from a file or directory path. + * + * Handles edge cases such as root-level files (`"/file.txt"` → `"/"`), + * paths without slashes (`"file.txt"` → `""`), and trailing slashes. + */ +export function parentDirectory(path: string): string { + const normalized = + path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path; + const lastSlash = normalized.lastIndexOf("/"); + + if (lastSlash > 0) return normalized.substring(0, lastSlash); + if (normalized.startsWith("/")) return "/"; + return ""; +} + +/** + * Sanitize a filename for use in a `Content-Disposition` HTTP header. + * + * Redundancy check – Unity Catalog is unlikely to allow filenames with + * quotes or control characters, but we sanitize defensively to prevent + * HTTP header injection if upstream constraints ever change. + */ +export function sanitizeFilename(raw: string): string { + return raw.replace(/["\\]/g, "\\$&").replace(/[\r\n]/g, ""); +} diff --git a/packages/appkit/src/plugins/files/manifest.json b/packages/appkit/src/plugins/files/manifest.json index 2bf5a4f7..db1726e0 100644 --- a/packages/appkit/src/plugins/files/manifest.json +++ b/packages/appkit/src/plugins/files/manifest.json @@ -13,7 +13,7 @@ "permission": "WRITE_VOLUME", "fields": { "path": { - "env": "DATABRICKS_VOLUME_PATH", + "env": "DATABRICKS_DEFAULT_VOLUME", "description": "Volume path (e.g. /Volumes/catalog/schema/volume_name)" } } diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 01622b1b..bfcf2a20 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -14,6 +14,7 @@ import { filesReadDefaults, filesWriteDefaults, } from "./defaults"; +import { parentDirectory, sanitizeFilename } from "./helpers"; import { filesManifest } from "./manifest"; import type { DownloadResponse, IFilesConfig } from "./types"; @@ -340,7 +341,7 @@ export class FilesPlugin extends Plugin { return; } - const fileName = path.split("/").pop() ?? "download"; + const fileName = sanitizeFilename(path.split("/").pop() ?? "download"); res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); res.setHeader( "Content-Type", @@ -392,7 +393,7 @@ export class FilesPlugin extends Plugin { res.setHeader("Content-Security-Policy", "sandbox"); if (!isSafeInlineContentType(resolvedType)) { - const fileName = path.split("/").pop() ?? "download"; + const fileName = sanitizeFilename(path.split("/").pop() ?? "download"); res.setHeader( "Content-Disposition", `attachment; filename="${fileName}"`, @@ -525,8 +526,7 @@ export class FilesPlugin extends Plugin { return; } - const parentDir = path.substring(0, path.lastIndexOf("/")) || path; - this._invalidateListCache(this._resolvePath(parentDir)); + this._invalidateListCache(this._resolvePath(parentDirectory(path))); logger.debug(req, "Upload complete: path=%s", path); res.json(result); @@ -558,8 +558,7 @@ export class FilesPlugin extends Plugin { return; } - const parentDir = dirPath.substring(0, dirPath.lastIndexOf("/")) || dirPath; - this._invalidateListCache(this._resolvePath(parentDir)); + this._invalidateListCache(this._resolvePath(parentDirectory(dirPath))); res.json(result); } @@ -588,8 +587,7 @@ export class FilesPlugin extends Plugin { return; } - const parentDir = path.substring(0, path.lastIndexOf("/")) || path; - this._invalidateListCache(this._resolvePath(parentDir)); + this._invalidateListCache(this._resolvePath(parentDirectory(path))); res.json(result); } diff --git a/packages/appkit/src/plugins/files/tests/helpers.test.ts b/packages/appkit/src/plugins/files/tests/helpers.test.ts index d30154f5..48ae4218 100644 --- a/packages/appkit/src/plugins/files/tests/helpers.test.ts +++ b/packages/appkit/src/plugins/files/tests/helpers.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest"; -import { contentTypeFromPath, isTextContentType } from "../helpers"; +import { + contentTypeFromPath, + isTextContentType, + parentDirectory, + sanitizeFilename, +} from "../helpers"; describe("contentTypeFromPath", () => { test("works without reported type", () => { @@ -119,3 +124,60 @@ describe("isTextContentType", () => { expect(isTextContentType("image/jpeg")).toBe(false); }); }); + +describe("parentDirectory", () => { + test("extracts parent from nested path", () => { + expect(parentDirectory("/Volumes/catalog/schema/vol/file.txt")).toBe( + "/Volumes/catalog/schema/vol", + ); + }); + + test("extracts parent from two-segment path", () => { + expect(parentDirectory("/dir/file.txt")).toBe("/dir"); + }); + + test("returns root for root-level file", () => { + expect(parentDirectory("/file.txt")).toBe("/"); + }); + + test("returns empty string for relative path without slash", () => { + expect(parentDirectory("file.txt")).toBe(""); + }); + + test("strips trailing slash before computing parent", () => { + expect(parentDirectory("/dir/subdir/")).toBe("/dir"); + }); + + test("handles root path with trailing slash", () => { + expect(parentDirectory("/")).toBe("/"); + }); + + test("handles relative nested path", () => { + expect(parentDirectory("subdir/file.txt")).toBe("subdir"); + }); +}); + +describe("sanitizeFilename", () => { + test("passes through clean filenames unchanged", () => { + expect(sanitizeFilename("report.pdf")).toBe("report.pdf"); + expect(sanitizeFilename("my-file_v2.txt")).toBe("my-file_v2.txt"); + }); + + test("escapes double quotes", () => { + expect(sanitizeFilename('file"name.txt')).toBe('file\\"name.txt'); + }); + + test("escapes backslashes", () => { + expect(sanitizeFilename("file\\name.txt")).toBe("file\\\\name.txt"); + }); + + test("strips carriage returns and newlines", () => { + expect(sanitizeFilename("file\r\nname.txt")).toBe("filename.txt"); + expect(sanitizeFilename("file\rname.txt")).toBe("filename.txt"); + expect(sanitizeFilename("file\nname.txt")).toBe("filename.txt"); + }); + + test("handles combined special characters", () => { + expect(sanitizeFilename('a"b\\c\r\nd.txt')).toBe('a\\"b\\\\cd.txt'); + }); +}); diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index 65583c8a..fb7ec39a 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -70,7 +70,7 @@ describe("Files Plugin Integration", () => { beforeAll(async () => { setupDatabricksEnv({ - DATABRICKS_VOLUME_PATH: "/Volumes/catalog/schema/vol", + DATABRICKS_DEFAULT_VOLUME: "/Volumes/catalog/schema/vol", }); ServiceContext.reset(); @@ -96,7 +96,7 @@ describe("Files Plugin Integration", () => { }); afterAll(async () => { - delete process.env.DATABRICKS_VOLUME_PATH; + delete process.env.DATABRICKS_DEFAULT_VOLUME; serviceContextMock?.restore(); if (server) { await new Promise((resolve, reject) => { From 07b6671979bb9e5683999d1f3336bd15eb0db3f2 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 23 Feb 2026 14:59:55 +0100 Subject: [PATCH 21/23] clean up JSDoc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/appkit/src/connectors/files/defaults.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts index 96b6350b..4844bace 100644 --- a/packages/appkit/src/connectors/files/defaults.ts +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -45,19 +45,6 @@ export function isTextContentType(contentType: string | undefined): boolean { return TEXT_KEYWORDS.some((kw) => contentType.includes(kw)); } -/** - * Resolve the MIME content type for a file path. - * - * Resolution order: - * 1. Custom type map (if the extension matches a key in `customTypes`). - * 2. Built-in extension map ({@link EXTENSION_CONTENT_TYPES}). - * 3. The `reported` type from the server, or `application/octet-stream` as a fallback. - * - * @param filePath - File path used to extract the extension. - * @param reported - Content type reported by the server (used as fallback). - * @param customTypes - Optional map of extensions to MIME types that takes priority. - * @returns The resolved MIME content type string. - */ /** * MIME types that are safe to serve inline (i.e. browsers cannot execute * scripts from them). Any type **not** in this set should be forced to From e065cdcc793d2d6cd93483236ec6147e195fc85d Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 23 Feb 2026 18:08:47 +0100 Subject: [PATCH 22/23] docs: format / cleanup --- .../client/src/appKitTypes.d.ts | 71 +++---------------- packages/appkit/src/plugins/files/defaults.ts | 21 +++++- packages/appkit/src/plugins/files/plugin.ts | 21 ------ packages/appkit/src/stream/stream-manager.ts | 10 --- 4 files changed, 27 insertions(+), 96 deletions(-) diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index 0e0ae0b0..049fae9f 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -14,46 +14,28 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType STRING */ - day_of_week: string; - /** @sqlType DECIMAL(35,2) */ - spend: number; + ; }>; }; apps_list: { name: "apps_list"; parameters: Record; result: Array<{ - /** @sqlType STRING */ - id: string; - /** @sqlType STRING */ - name: string; - /** @sqlType STRING */ - creator: string; - /** @sqlType STRING */ - tags: string; - /** @sqlType DECIMAL(38,6) */ - totalSpend: number; - /** @sqlType DATE */ - createdAt: string; + ; }>; }; cost_recommendations: { name: "cost_recommendations"; parameters: Record; result: Array<{ - /** @sqlType INT */ - dummy: number; + ; }>; }; example: { name: "example"; parameters: Record; result: Array<{ - /** @sqlType BOOLEAN */ - "(1 = 1)": boolean; + ; }>; }; spend_data: { @@ -73,12 +55,7 @@ declare module "@databricks/appkit-ui/react" { creator: SQLStringMarker; }; result: Array<{ - /** @sqlType STRING */ - group_key: string; - /** @sqlType TIMESTAMP */ - aggregation_period: string; - /** @sqlType DECIMAL(38,6) */ - cost_usd: number; + ; }>; }; spend_summary: { @@ -92,12 +69,7 @@ declare module "@databricks/appkit-ui/react" { startDate: SQLDateMarker; }; result: Array<{ - /** @sqlType DECIMAL(33,0) */ - total: number; - /** @sqlType DECIMAL(33,0) */ - average: number; - /** @sqlType DECIMAL(33,0) */ - forecasted: number; + ; }>; }; sql_helpers_test: { @@ -117,22 +89,7 @@ declare module "@databricks/appkit-ui/react" { binaryParam: SQLStringMarker; }; result: Array<{ - /** @sqlType STRING */ - string_value: string; - /** @sqlType STRING */ - number_value: string; - /** @sqlType STRING */ - boolean_value: string; - /** @sqlType STRING */ - date_value: string; - /** @sqlType STRING */ - timestamp_value: string; - /** @sqlType BINARY */ - binary_value: string; - /** @sqlType STRING */ - binary_hex: string; - /** @sqlType INT */ - binary_length: number; + ; }>; }; top_contributors: { @@ -146,10 +103,7 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType DECIMAL(38,6) */ - total_cost_usd: number; + ; }>; }; untagged_apps: { @@ -163,14 +117,7 @@ declare module "@databricks/appkit-ui/react" { endDate: SQLDateMarker; }; result: Array<{ - /** @sqlType STRING */ - app_name: string; - /** @sqlType STRING */ - creator: string; - /** @sqlType DECIMAL(38,6) */ - total_cost_usd: number; - /** @sqlType DECIMAL(38,10) */ - avg_period_cost_usd: number; + ; }>; }; } diff --git a/packages/appkit/src/plugins/files/defaults.ts b/packages/appkit/src/plugins/files/defaults.ts index bb26ce01..e0284808 100644 --- a/packages/appkit/src/plugins/files/defaults.ts +++ b/packages/appkit/src/plugins/files/defaults.ts @@ -1,6 +1,11 @@ import type { PluginExecuteConfig } from "shared"; -/** Execution defaults for read-tier operations (list, read, exists, metadata, preview). Cache 60 s, retry 3x with 1 s backoff, 30 s timeout. */ +/** + * Execution defaults for read-tier operations (list, read, exists, metadata, preview). + * Cache 60s + * Retry 3x with 1s backoff + * Timeout 30s + **/ export const filesReadDefaults: PluginExecuteConfig = { cache: { enabled: true, @@ -14,7 +19,12 @@ export const filesReadDefaults: PluginExecuteConfig = { timeout: 30_000, }; -/** Execution defaults for download-tier operations (download, raw). No cache, retry 3x with 1 s backoff, 30 s timeout (stream start only). */ +/** + * Execution defaults for download-tier operations (download, raw). + * No cache + * Retry 3x with 1s backoff + * Timeout 30s (stream start only) + **/ export const filesDownloadDefaults: PluginExecuteConfig = { cache: { enabled: false, @@ -30,7 +40,12 @@ export const filesDownloadDefaults: PluginExecuteConfig = { timeout: 30_000, }; -/** Execution defaults for write-tier operations (upload, mkdir, delete). No cache, no retry, 600 s timeout. */ +/** + * Execution defaults for write-tier operations (upload, mkdir, delete). + * No cache + * No retry + * Timeout 600s. + **/ export const filesWriteDefaults: PluginExecuteConfig = { cache: { enabled: false, diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index bfcf2a20..3460c2b0 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -624,27 +624,6 @@ export class FilesPlugin extends Plugin { } } -/** - * Files plugin for Databricks Unity Catalog volume operations. - * - * Provides HTTP routes and a programmatic API for listing, reading, - * downloading, uploading, deleting, and previewing files with built-in - * caching, retry, and timeout handling via the execution interceptor pipeline. - * - * Routes are mounted at `/api/files/*`. - * - * @example - * ```typescript - * import { createApp, files } from "@databricks/appkit"; - * - * const app = await createApp({ - * plugins: [ - * files({ defaultVolume: "/Volumes/catalog/schema/vol" }), - * ], - * }); - * ``` - */ - /** * @internal */ diff --git a/packages/appkit/src/stream/stream-manager.ts b/packages/appkit/src/stream/stream-manager.ts index e92f5818..429efe73 100644 --- a/packages/appkit/src/stream/stream-manager.ts +++ b/packages/appkit/src/stream/stream-manager.ts @@ -9,8 +9,6 @@ import { StreamRegistry } from "./stream-registry"; import { SSEErrorCode, type StreamEntry, type StreamOperation } from "./types"; import { StreamValidator } from "./validator"; -const logger = createLogger("stream"); - // main entry point for Server-Sent events streaming export class StreamManager { private activeOperations: Set; @@ -79,12 +77,6 @@ export class StreamManager { streamEntry: StreamEntry, options?: StreamConfig, ): Promise { - logger.debug( - "Client reconnecting to stream: streamId=%s, clients=%d", - streamEntry.streamId, - streamEntry.clients.size, - ); - // handle reconnection - replay missed events const lastEventId = res.req?.headers["last-event-id"]; @@ -196,8 +188,6 @@ export class StreamManager { }; this.streamRegistry.add(streamEntry); - logger.debug("New stream created: streamId=%s", streamId); - // track operation const streamOperation: StreamOperation = { controller: abortController, From 34ef4a5bb06ad19475a142ab03f4230dcd60aea5 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 23 Feb 2026 18:20:50 +0100 Subject: [PATCH 23/23] fix: edge case on extension matching / add tests --- .../appkit/src/connectors/files/defaults.ts | 3 +- .../connectors/files/tests/defaults.test.ts | 28 +++++++++++++++++++ packages/appkit/src/plugins/files/index.ts | 1 - 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/connectors/files/defaults.ts b/packages/appkit/src/connectors/files/defaults.ts index 4844bace..7791de6d 100644 --- a/packages/appkit/src/connectors/files/defaults.ts +++ b/packages/appkit/src/connectors/files/defaults.ts @@ -88,7 +88,8 @@ export function contentTypeFromPath( reported?: string, customTypes?: Record, ): string { - const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); + const dotIndex = filePath.lastIndexOf("."); + const ext = dotIndex > 0 ? filePath.slice(dotIndex).toLowerCase() : ""; const fromCustom = customTypes?.[ext]; if (fromCustom) { diff --git a/packages/appkit/src/connectors/files/tests/defaults.test.ts b/packages/appkit/src/connectors/files/tests/defaults.test.ts index 70d09dbf..578eb91c 100644 --- a/packages/appkit/src/connectors/files/tests/defaults.test.ts +++ b/packages/appkit/src/connectors/files/tests/defaults.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + contentTypeFromPath, isSafeInlineContentType, SAFE_INLINE_CONTENT_TYPES, } from "../defaults"; @@ -52,3 +53,30 @@ describe("isSafeInlineContentType", () => { } }); }); + +describe("contentTypeFromPath", () => { + test("returns octet-stream for files without an extension", () => { + expect(contentTypeFromPath("Makefile")).toBe("application/octet-stream"); + expect(contentTypeFromPath("/path/to/Makefile")).toBe( + "application/octet-stream", + ); + }); + + test("falls back to reported type for files without an extension", () => { + expect(contentTypeFromPath("LICENSE", "text/plain")).toBe("text/plain"); + }); + + test("returns octet-stream for dotfiles without a real extension", () => { + expect(contentTypeFromPath(".gitignore")).toBe("application/octet-stream"); + expect(contentTypeFromPath(".env")).toBe("application/octet-stream"); + }); + + test("resolves dotfiles that have an extension", () => { + expect(contentTypeFromPath(".eslintrc.json")).toBe("application/json"); + expect(contentTypeFromPath(".config.yaml")).toBe("application/x-yaml"); + }); + + test("returns octet-stream for empty string", () => { + expect(contentTypeFromPath("")).toBe("application/octet-stream"); + }); +}); diff --git a/packages/appkit/src/plugins/files/index.ts b/packages/appkit/src/plugins/files/index.ts index 8ca86436..7da6ec4d 100644 --- a/packages/appkit/src/plugins/files/index.ts +++ b/packages/appkit/src/plugins/files/index.ts @@ -1,5 +1,4 @@ export * from "./defaults"; -export * from "./helpers"; export * from "./manifest"; export * from "./plugin"; export * from "./types";