From 63bdb0e0cdeb3fee987611ce9d60333f83c67794 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 17 Feb 2026 12:30:16 +0100 Subject: [PATCH 01/16] 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 b0745592..5bf6c708 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -30,7 +30,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 8127dbea98e3a86bafb4b6405ec2adf397424c17 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Tue, 17 Feb 2026 14:09:15 +0100 Subject: [PATCH 02/16] 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 + 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 +- 14 files changed, 972 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 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 f9b31113..3c4d0a51 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -13,6 +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' +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' @@ -38,6 +39,11 @@ const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ path: '/reconnect', 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', @@ -64,6 +70,7 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -74,6 +81,7 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -85,6 +93,7 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -97,6 +106,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -107,6 +117,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -117,6 +128,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -128,6 +140,7 @@ export interface RootRouteChildren { AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute + FilesRouteRoute: typeof FilesRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute @@ -164,6 +177,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReconnectRouteRouteImport parentRoute: typeof rootRouteImport } + '/files': { + id: '/files' + path: '/files' + fullPath: '/files' + preLoaderRoute: typeof FilesRouteRouteImport + parentRoute: typeof rootRouteImport + } '/data-visualization': { id: '/data-visualization' path: '/data-visualization' @@ -200,6 +220,7 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, + FilesRouteRoute: FilesRouteRoute, 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 b2faa651..cef4aaee 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -72,6 +72,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 c6d5b7fc..931c53f8 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 a56ba4a7..538e7c90 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,4 +1,11 @@ -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 { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -19,6 +26,7 @@ createApp({ reconnect(), telemetryExamples(), analytics({}), + files({ defaultVolume: process.env.DATABRICKS_DEFAULT_VOLUME }), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), }).then((appkit) => { @@ -58,6 +66,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 f1a0e5f8..053d214f 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -59,6 +59,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. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | | [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. Normalizes string type/permission to strict ResourceType/ResourcePermission. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index aa114b63..59a7a119 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -170,6 +170,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/index.ts b/packages/appkit/src/index.ts index 5bf6c708..f5a8c693 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,6 +31,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 0c925bdcd8d85fc1e707d05a66a50ca0938ecdce Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 10:51:12 +0100 Subject: [PATCH 03/16] 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 538e7c90..ea5562cb 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,11 +1,4 @@ -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 { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -66,224 +59,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 a002dbb5d3771db653167d2b341d9820b7223d4f Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 12:39:32 +0100 Subject: [PATCH 04/16] 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 45a84af58858907c7be36401bcc080bf07d50d76 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 15:09:52 +0100 Subject: [PATCH 05/16] 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 697553f80bbccedf8da224139f790d35f551a5bd Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 16:12:14 +0100 Subject: [PATCH 06/16] 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 5920c01029836f0a2a7d874c2cc11b7e0cb2a48e Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 16:59:15 +0100 Subject: [PATCH 07/16] 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 aa76021e93e68e52ab2fe880402c9be5048d219a Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 17:07:23 +0100 Subject: [PATCH 08/16] 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 | 21 - .../client/src/routes/__root.tsx | 8 - .../client/src/routes/files.route.tsx | 617 ------------------ .../client/src/routes/index.tsx | 18 - apps/dev-playground/server/index.ts | 3 +- 6 files changed, 63 insertions(+), 675 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 3c4d0a51..f9b31113 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -13,7 +13,6 @@ 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' -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' @@ -39,11 +38,6 @@ const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ path: '/reconnect', 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,7 +64,6 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute - '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -81,7 +74,6 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute - '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -93,7 +85,6 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute - '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -106,7 +97,6 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' - | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -117,7 +107,6 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' - | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -128,7 +117,6 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' - | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -140,7 +128,6 @@ export interface RootRouteChildren { AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute - FilesRouteRoute: typeof FilesRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute @@ -177,13 +164,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReconnectRouteRouteImport 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,7 +200,6 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, - FilesRouteRoute: FilesRouteRoute, 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 cef4aaee..b2faa651 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -72,14 +72,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 370928c7..c6d5b7fc 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. -

- -
-
-

diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index ea5562cb..a56ba4a7 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,4 +1,4 @@ -import { analytics, createApp, files, server } from "@databricks/appkit"; +import { analytics, createApp, server } from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -19,7 +19,6 @@ createApp({ reconnect(), telemetryExamples(), analytics({}), - files({ defaultVolume: process.env.DATABRICKS_DEFAULT_VOLUME }), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), }).then((appkit) => { From 4678f9ddccd4cebe385cf43fcb968b2cb290fa03 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 17:35:52 +0100 Subject: [PATCH 09/16] 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 053d214f..f1a0e5f8 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -59,7 +59,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. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | | [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. Normalizes string type/permission to strict ResourceType/ResourcePermission. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 59a7a119..aa114b63 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -170,11 +170,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 f5a8c693..5bf6c708 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,7 +31,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 bab34608ee2ddf7dd769bf96ac507cbe19fd27f4 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 10:13:34 +0100 Subject: [PATCH 10/16] 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 2c17e89e709b2180a8813405bb15f52f070a0e66 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 15:22:20 +0100 Subject: [PATCH 11/16] 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 5bf6c708..13c7b171 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,6 +31,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 dd51254108ef79e286dbd6966106b5053635ca63 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 15:46:20 +0100 Subject: [PATCH 12/16] refactor: move client to `/connectors` --- .../appkit/src/connectors/files/client.ts | 341 ++++++++++++++++++ .../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, 524 insertions(+), 366 deletions(-) create mode 100644 packages/appkit/src/connectors/files/client.ts 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 new file mode 100644 index 00000000..f4df10a5 --- /dev/null +++ b/packages/appkit/src/connectors/files/client.ts @@ -0,0 +1,341 @@ +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; +} + +export class FilesConnector { + private readonly name = "files"; + private defaultVolume: string | undefined; + + private readonly telemetry: TelemetryProvider; + private readonly telemetryMetrics: { + operationCount: Counter; + operationDuration: Histogram; + }; + + constructor(config: FilesConnectorConfig) { + this.defaultVolume = config.defaultVolume; + + 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, + }); + } + + 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"]), + 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 = + 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 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/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 01d23cf2..ac118ae3 100644 --- a/packages/appkit/src/connectors/index.ts +++ b/packages/appkit/src/connectors/index.ts @@ -1,2 +1,3 @@ +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 8fa2314fa5bc7dc0c3ae88381a85d61812c36aa9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 19 Feb 2026 16:22:53 +0100 Subject: [PATCH 13/16] chore: improve preview and MIME checks --- .../appkit/src/connectors/files/client.ts | 18 ++-- .../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, 137 insertions(+), 18 deletions(-) diff --git a/packages/appkit/src/connectors/files/client.ts b/packages/appkit/src/connectors/files/client.ts index f4df10a5..883b8d7e 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"); @@ -24,11 +24,13 @@ 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: { @@ -38,6 +40,7 @@ export class FilesConnector { constructor(config: FilesConnectorConfig) { this.defaultVolume = config.defaultVolume; + this.customContentTypes = config.customContentTypes; this.telemetry = TelemetryManager.getProvider(this.name, config.telemetry); this.telemetryMetrics = { @@ -72,6 +75,7 @@ export class FilesConnector { return new FilesConnector({ defaultVolume: volumePath, telemetry: false, + customContentTypes: this.customContentTypes, }); } @@ -211,7 +215,11 @@ export class FilesConnector { }); return { contentLength: response["content-length"], - contentType: contentTypeFromPath(filePath, response["content-type"]), + contentType: contentTypeFromPath( + filePath, + response["content-type"], + this.customContentTypes, + ), lastModified: response["last-modified"], }; }, @@ -299,11 +307,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 7e005eb31bf15fc7769f56446ccd1c1929b8613e Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 17:10:53 +0100 Subject: [PATCH 14/16] chore: add files route and UI to dev-playground Co-Authored-By: Claude Opus 4.6 Signed-off-by: Atila Fassina --- .../client/src/appKitTypes.d.ts | 71 +- .../client/src/routeTree.gen.ts | 21 + .../client/src/routes/__root.tsx | 8 + .../client/src/routes/files.route.tsx | 617 ++++++++++++++++++ .../client/src/routes/index.tsx | 18 + apps/dev-playground/server/index.ts | 3 +- 6 files changed, 675 insertions(+), 63 deletions(-) create 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 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 f9b31113..3c4d0a51 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -13,6 +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' +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' @@ -38,6 +39,11 @@ const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ path: '/reconnect', 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', @@ -64,6 +70,7 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -74,6 +81,7 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -85,6 +93,7 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/data-visualization': typeof DataVisualizationRouteRoute + '/files': typeof FilesRouteRoute '/reconnect': typeof ReconnectRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute @@ -97,6 +106,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -107,6 +117,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -117,6 +128,7 @@ export interface FileRouteTypes { | '/analytics' | '/arrow-analytics' | '/data-visualization' + | '/files' | '/reconnect' | '/sql-helpers' | '/telemetry' @@ -128,6 +140,7 @@ export interface RootRouteChildren { AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute + FilesRouteRoute: typeof FilesRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute @@ -164,6 +177,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReconnectRouteRouteImport parentRoute: typeof rootRouteImport } + '/files': { + id: '/files' + path: '/files' + fullPath: '/files' + preLoaderRoute: typeof FilesRouteRouteImport + parentRoute: typeof rootRouteImport + } '/data-visualization': { id: '/data-visualization' path: '/data-visualization' @@ -200,6 +220,7 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, + FilesRouteRoute: FilesRouteRoute, 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 b2faa651..cef4aaee 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -72,6 +72,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..67317eb4 --- /dev/null +++ b/apps/dev-playground/client/src/routes/files.route.tsx @@ -0,0 +1,617 @@ +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 c6d5b7fc..370928c7 100644 --- a/apps/dev-playground/client/src/routes/index.tsx +++ b/apps/dev-playground/client/src/routes/index.tsx @@ -126,6 +126,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 a56ba4a7..ea5562cb 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,4 +1,4 @@ -import { analytics, createApp, server } from "@databricks/appkit"; +import { analytics, createApp, files, server } from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; import { reconnect } from "./reconnect-plugin"; import { telemetryExamples } from "./telemetry-example-plugin"; @@ -19,6 +19,7 @@ createApp({ reconnect(), telemetryExamples(), analytics({}), + files({ defaultVolume: process.env.DATABRICKS_DEFAULT_VOLUME }), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), }).then((appkit) => { From 2862caa0da2e1fa0ff9b5dd60b4c9b47a698a566 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 18 Feb 2026 17:25:07 +0100 Subject: [PATCH 15/16] docs: regen --- docs/docs/api/appkit/Class.Plugin.md | 18 ++++++++++++++++++ packages/appkit/src/plugins/files/index.ts | 1 - 2 files changed, 18 insertions(+), 1 deletion(-) 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/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"; From 8a2a15f1e41d4d310bc96aee5bcad570e92f8358 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:00:25 +0100 Subject: [PATCH 16/16] chore: merge base branch conflicts and add files route to dev-playground (#122) * Initial plan * chore: merge base branch plugin/files changes to fix conflicts Co-authored-by: atilafassina <2382552+atilafassina@users.noreply.github.com> * chore: remove old tools/dist.ts refactored in base branch Co-authored-by: atilafassina <2382552+atilafassina@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: atilafassina <2382552+atilafassina@users.noreply.github.com> --- .github/workflows/release-lakebase.yml | 69 ++ .github/workflows/release.yml | 55 ++ .release-it.json | 14 +- CHANGELOG.md | 20 + CLAUDE.md | 33 + NOTICE.md | 2 +- README.md | 22 +- apps/dev-playground/.env.dist | 5 + apps/dev-playground/client/package-lock.json | 31 +- .../components/lakebase/ActivityLogsPanel.tsx | 259 ++++++ .../src/components/lakebase/OrdersPanel.tsx | 524 ++++++++++++ .../src/components/lakebase/ProductsPanel.tsx | 305 +++++++ .../src/components/lakebase/TasksPanel.tsx | 401 +++++++++ .../client/src/components/lakebase/index.ts | 4 + .../client/src/hooks/use-lakebase-data.ts | 144 ++++ .../client/src/routeTree.gen.ts | 21 + .../client/src/routes/__root.tsx | 8 + .../client/src/routes/index.tsx | 19 + .../client/src/routes/lakebase.route.tsx | 64 ++ apps/dev-playground/package.json | 4 + apps/dev-playground/server/index.ts | 3 + .../server/lakebase-examples-plugin.ts | 85 ++ .../lakebase-examples/drizzle-example.ts | 192 +++++ .../lakebase-examples/raw-driver-example.ts | 175 ++++ .../lakebase-examples/sequelize-example.ts | 273 +++++++ .../lakebase-examples/typeorm-example.ts | 201 +++++ apps/dev-playground/tsconfig.json | 4 +- ...numeration.RequestedClaimsPermissionSet.md | 13 + .../appkit/Function.contentTypeFromPath.md | 29 + .../api/appkit/Function.createLakebasePool.md | 20 + .../Function.generateDatabaseCredential.md | 56 ++ .../appkit/Function.getLakebaseOrmConfig.md | 84 ++ .../appkit/Function.getLakebasePgConfig.md | 31 + .../api/appkit/Function.getWorkspaceClient.md | 17 + .../appkit/Interface.DatabaseCredential.md | 30 + ...rface.GenerateDatabaseCredentialRequest.md | 45 ++ .../appkit/Interface.LakebasePoolConfig.md | 116 +++ .../api/appkit/Interface.RequestedClaims.md | 24 + .../api/appkit/Interface.RequestedResource.md | 29 + docs/docs/api/appkit/index.md | 12 + docs/docs/api/appkit/typedoc-sidebar.ts | 60 ++ docs/docs/plugins.md | 221 ++++- package.json | 2 +- packages/appkit-ui/package.json | 4 +- packages/appkit/package.json | 11 +- packages/appkit/src/cache/index.ts | 11 +- .../appkit/src/cache/storage/persistent.ts | 51 +- .../src/cache/tests/cache-manager.test.ts | 52 +- .../appkit/src/cache/tests/persistent.test.ts | 123 ++- .../appkit/src/connectors/files/client.ts | 15 +- .../appkit/src/connectors/files/defaults.ts | 52 ++ .../src/connectors/files/tests/client.test.ts | 24 +- .../connectors/files/tests/defaults.test.ts | 54 ++ packages/appkit/src/connectors/index.ts | 1 + .../appkit/src/connectors/lakebase/index.ts | 42 + packages/appkit/src/index.ts | 17 + packages/appkit/src/plugins/files/defaults.ts | 3 + packages/appkit/src/plugins/files/index.ts | 1 + packages/appkit/src/plugins/files/plugin.ts | 141 +++- .../files/tests/plugin.integration.test.ts | 110 +++ .../src/plugins/files/tests/plugin.test.ts | 80 +- packages/appkit/src/plugins/files/types.ts | 22 +- packages/appkit/tsconfig.json | 3 +- packages/lakebase/.release-it.json | 40 + packages/lakebase/CHANGELOG.md | 18 + packages/lakebase/README.md | 278 +++++++ packages/lakebase/package.json | 65 ++ .../src/__tests__/credentials.test.ts | 187 +++++ .../lakebase/src/__tests__/logger.test.ts | 184 +++++ packages/lakebase/src/__tests__/pool.test.ts | 760 ++++++++++++++++++ packages/lakebase/src/config.ts | 145 ++++ packages/lakebase/src/credentials.ts | 95 +++ packages/lakebase/src/errors.ts | 66 ++ packages/lakebase/src/index.ts | 20 + packages/lakebase/src/logger.ts | 54 ++ packages/lakebase/src/pool-config.ts | 128 +++ packages/lakebase/src/pool.ts | 142 ++++ packages/lakebase/src/telemetry.ts | 96 +++ packages/lakebase/src/token-refresh.ts | 115 +++ packages/lakebase/src/types.ts | 211 +++++ packages/lakebase/tsconfig.json | 12 + packages/lakebase/tsdown.config.ts | 28 + .../shared/src/cli/commands/generate-types.ts | 16 +- pnpm-lock.yaml | 521 +++++++++++- template/package-lock.json | 167 +++- template/package.json | 4 +- tools/{dist.ts => dist-appkit.ts} | 75 +- tools/dist-lakebase.ts | 25 + tools/license-utils.ts | 1 + .../prepared-files/root-tsconfig.json | 6 +- tools/publish-template-tag.ts | 90 +++ vitest.config.ts | 8 + 92 files changed, 7789 insertions(+), 311 deletions(-) create mode 100644 .github/workflows/release-lakebase.yml create mode 100644 apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx create mode 100644 apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx create mode 100644 apps/dev-playground/client/src/components/lakebase/ProductsPanel.tsx create mode 100644 apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx create mode 100644 apps/dev-playground/client/src/components/lakebase/index.ts create mode 100644 apps/dev-playground/client/src/hooks/use-lakebase-data.ts create mode 100644 apps/dev-playground/client/src/routes/lakebase.route.tsx create mode 100644 apps/dev-playground/server/lakebase-examples-plugin.ts create mode 100644 apps/dev-playground/server/lakebase-examples/drizzle-example.ts create mode 100644 apps/dev-playground/server/lakebase-examples/raw-driver-example.ts create mode 100644 apps/dev-playground/server/lakebase-examples/sequelize-example.ts create mode 100644 apps/dev-playground/server/lakebase-examples/typeorm-example.ts create mode 100644 docs/docs/api/appkit/Enumeration.RequestedClaimsPermissionSet.md create mode 100644 docs/docs/api/appkit/Function.contentTypeFromPath.md create mode 100644 docs/docs/api/appkit/Function.createLakebasePool.md create mode 100644 docs/docs/api/appkit/Function.generateDatabaseCredential.md create mode 100644 docs/docs/api/appkit/Function.getLakebaseOrmConfig.md create mode 100644 docs/docs/api/appkit/Function.getLakebasePgConfig.md create mode 100644 docs/docs/api/appkit/Function.getWorkspaceClient.md create mode 100644 docs/docs/api/appkit/Interface.DatabaseCredential.md create mode 100644 docs/docs/api/appkit/Interface.GenerateDatabaseCredentialRequest.md create mode 100644 docs/docs/api/appkit/Interface.LakebasePoolConfig.md create mode 100644 docs/docs/api/appkit/Interface.RequestedClaims.md create mode 100644 docs/docs/api/appkit/Interface.RequestedResource.md create mode 100644 packages/appkit/src/connectors/files/tests/defaults.test.ts create mode 100644 packages/appkit/src/connectors/lakebase/index.ts create mode 100644 packages/lakebase/.release-it.json create mode 100644 packages/lakebase/CHANGELOG.md create mode 100644 packages/lakebase/README.md create mode 100644 packages/lakebase/package.json create mode 100644 packages/lakebase/src/__tests__/credentials.test.ts create mode 100644 packages/lakebase/src/__tests__/logger.test.ts create mode 100644 packages/lakebase/src/__tests__/pool.test.ts create mode 100644 packages/lakebase/src/config.ts create mode 100644 packages/lakebase/src/credentials.ts create mode 100644 packages/lakebase/src/errors.ts create mode 100644 packages/lakebase/src/index.ts create mode 100644 packages/lakebase/src/logger.ts create mode 100644 packages/lakebase/src/pool-config.ts create mode 100644 packages/lakebase/src/pool.ts create mode 100644 packages/lakebase/src/telemetry.ts create mode 100644 packages/lakebase/src/token-refresh.ts create mode 100644 packages/lakebase/src/types.ts create mode 100644 packages/lakebase/tsconfig.json create mode 100644 packages/lakebase/tsdown.config.ts rename tools/{dist.ts => dist-appkit.ts} (65%) create mode 100644 tools/dist-lakebase.ts create mode 100644 tools/publish-template-tag.ts diff --git a/.github/workflows/release-lakebase.yml b/.github/workflows/release-lakebase.yml new file mode 100644 index 00000000..352bf990 --- /dev/null +++ b/.github/workflows/release-lakebase.yml @@ -0,0 +1,69 @@ +name: Release @databricks/lakebase + +on: + push: + branches: + - main + paths: + - 'packages/lakebase/**' + workflow_dispatch: + inputs: + dry-run: + description: "Dry run (no actual release)" + required: false + type: boolean + default: false + +jobs: + release: + runs-on: + group: databricks-protected-runner-group + labels: linux-ubuntu-latest + + environment: release + + env: + DRY_RUN: ${{ inputs.dry-run == true }} + + permissions: + contents: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Release + working-directory: packages/lakebase + run: | + if [ "$DRY_RUN" == "true" ]; then + pnpm release:dry + else + pnpm release:ci + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ff58ac8..86547abd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,9 @@ jobs: environment: release + outputs: + version: ${{ steps.version.outputs.version }} + permissions: contents: write id-token: write @@ -61,6 +64,18 @@ jobs: echo "dry_run=${{ inputs.dry-run }}" >> $GITHUB_OUTPUT fi + - name: Determine version + id: version + if: steps.mode.outputs.dry_run != 'true' + run: | + VERSION=$(pnpm exec release-it --release-version --ci 2>/dev/null) || true + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Next version: $VERSION" + else + echo "No releasable version detected" + fi + - name: Release run: | if [ "${{ steps.mode.outputs.dry_run }}" == "true" ]; then @@ -70,3 +85,43 @@ jobs: fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + sync-template: + runs-on: + group: databricks-protected-runner-group + labels: linux-ubuntu-latest + + needs: release + # in case a dry run is performed, the version is not set so we need to check for it. + if: needs.release.outputs.version != '' + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Sync template and push tag + run: pnpm exec tsx tools/publish-template-tag.ts ${{ needs.release.outputs.version }} diff --git a/.release-it.json b/.release-it.json index 980cc513..b3a56377 100644 --- a/.release-it.json +++ b/.release-it.json @@ -3,15 +3,19 @@ "git": { "commitMessage": "chore: release v${version} [skip ci]", "tagName": "v${version}", + "tagMatch": "v*", "tagAnnotation": "Release v${version}", "requireBranch": "main", "requireCleanWorkingDir": true, + "requireCommits": true, + "requireCommitsFail": false, + "commitsPath": "packages/appkit packages/appkit-ui packages/shared", "push": true, "pushArgs": ["--follow-tags"] }, "github": { "release": true, - "releaseName": "v${version}", + "releaseName": "AppKit v${version}", "autoGenerate": false, "draft": false, "preRelease": false, @@ -35,7 +39,13 @@ "commitsSort": ["type", "subject"] }, "infile": "CHANGELOG.md", - "header": "# Changelog\n\nAll notable changes to this project will be documented in this file." + "header": "# Changelog\n\nAll notable changes to this project will be documented in this file.", + "gitRawCommitsOpts": { + "path": ["packages/appkit", "packages/appkit-ui", "packages/shared"] + }, + "commitsOpts": { + "path": ["packages/appkit", "packages/appkit-ui", "packages/shared"] + } } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index f8531183..b6871d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [0.7.4](https://github.com/databricks/appkit/compare/v0.7.3...v0.7.4) (2026-02-18) + +* typegen command ([#119](https://github.com/databricks/appkit/issues/119)) ([8c3735c](https://github.com/databricks/appkit/commit/8c3735c6b27c5e5cbacb81363ccaf4f6acbfd185)) + +## [0.7.3](https://github.com/databricks/appkit/compare/v0.7.2...v0.7.3) (2026-02-18) + +* release `@databricks/lakebase` correctly, fix `appkit` dependency ([#111](https://github.com/databricks/appkit/issues/111)) ([5b6856a](https://github.com/databricks/appkit/commit/5b6856a6b42680a671cfc3e99eab3bef72803fd1)) + +## [0.7.2](https://github.com/databricks/appkit/compare/v0.7.1...v0.7.2) (2026-02-18) + +* template sync ([#109](https://github.com/databricks/appkit/issues/109)) ([f250016](https://github.com/databricks/appkit/commit/f250016b28e24e3ce56d09a0a3d95088a689a943)) + +## [0.7.1](https://github.com/databricks/appkit/compare/v0.7.0...v0.7.1) (2026-02-18) + +* sync template versions on release ([#105](https://github.com/databricks/appkit/issues/105)) ([4cbe826](https://github.com/databricks/appkit/commit/4cbe8266e80e4b4cfe4b4e0594c2633dcba7123a)) + +## [0.7.0](https://github.com/databricks/appkit/compare/v0.6.0...v0.7.0) (2026-02-17) + +* introduce Lakebase Autoscaling driver ([#98](https://github.com/databricks/appkit/issues/98)) ([27b1848](https://github.com/databricks/appkit/commit/27b184886b2ab15c73f3d46f5ff9e9c6d8806c71)) + ## [0.6.0](https://github.com/databricks/appkit/compare/v0.5.4...v0.6.0) (2026-02-16) ### appkit diff --git a/CLAUDE.md b/CLAUDE.md index 1f380700..9a17f870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,39 @@ The AnalyticsPlugin provides SQL query execution: - Built-in caching with configurable TTL - Databricks SQL Warehouse connector for execution +### Lakebase Autoscaling Connector + +**Location:** `packages/appkit/src/connectors/lakebase/` + +AppKit provides `createLakebasePool()` - a factory function that returns a standard `pg.Pool` configured with automatic OAuth token refresh for Databricks Lakebase (OLTP) databases. + +**Key Features:** +- Returns standard `pg.Pool` (compatible with all ORMs) +- Automatic OAuth token refresh (1-hour tokens, 2-minute buffer) +- Token caching to minimize API calls +- Battle-tested pattern (same as AWS RDS IAM authentication) + +**Quick Example:** +```typescript +import { createLakebasePool } from '@databricks/appkit'; + +// Reads from PGHOST, PGDATABASE, LAKEBASE_ENDPOINT env vars +const pool = createLakebasePool(); + +// Standard pg.Pool API +const result = await pool.query('SELECT * FROM users'); +``` + +**ORM Integration:** +Works with Drizzle, Prisma, TypeORM - see [Lakebase Integration Docs](docs/docs/integrations/lakebase.md) for examples. + +**Architecture:** +- Connector files: `packages/appkit/src/connectors/lakebase/` + - `pool.ts` - Pool factory with OAuth token refresh + - `types.ts` - TypeScript interfaces (`LakebasePoolConfig`) + - `utils.ts` - Helper functions (`generateDatabaseCredential`) + - `auth-types.ts` - Lakebase v2 API types + ### Frontend-Backend Interaction ``` diff --git a/NOTICE.md b/NOTICE.md index f1c83b4c..55513f1d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -67,7 +67,7 @@ This Software contains code from the following open source projects: | [lucide-react](https://www.npmjs.com/package/lucide-react) | 0.554.0 | ISC | https://lucide.dev | | [next-themes](https://www.npmjs.com/package/next-themes) | 0.4.6 | MIT | https://github.com/pacocoursey/next-themes#readme | | [obug](https://www.npmjs.com/package/obug) | 2.1.1 | MIT | https://github.com/sxzz/obug#readme | -| [pg](https://www.npmjs.com/package/pg) | 8.16.3 | MIT | https://github.com/brianc/node-postgres | +| [pg](https://www.npmjs.com/package/pg) | 8.18.0 | MIT | https://github.com/brianc/node-postgres | | [react-day-picker](https://www.npmjs.com/package/react-day-picker) | 9.12.0 | MIT | https://daypicker.dev | | [react-hook-form](https://www.npmjs.com/package/react-hook-form) | 7.68.0 | MIT | https://react-hook-form.com | | [react-resizable-panels](https://www.npmjs.com/package/react-resizable-panels) | 3.0.6 | MIT | https://github.com/bvaughn/react-resizable-panels#readme | diff --git a/README.md b/README.md index aed20ac6..770e7eb9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Build Databricks Apps faster with our brand-new Node.js + React SDK. Built for humans and AI. -> [!WARNING] PREVIEW - NOT FOR PRODUCTION USE -> +> [!WARNING] +> PREVIEW - NOT FOR PRODUCTION USE + > **This SDK is in preview and is subject to change without notice.** > > - ❌ **Do NOT use in production environments** @@ -24,6 +25,23 @@ AppKit simplifies building data applications on Databricks by providing: - **Developer experience**: Remote hot reload, file-based queries, optimized for AI-assisted development - **Databricks native**: Seamless integration with SQL Warehouses, Unity Catalog, and other workspace resources +## Plugins + +AppKit's power comes from its plugin system. Each plugin adds a focused capability to your app with minimal configuration. + +### Available now + +- **Analytics Plugin** — Query your Lakehouse data directly from your app. Define SQL queries as files, execute them against Databricks SQL Warehouses, and get automatic caching, parameterization, and on-behalf-of user execution out of the box. Perfect for building apps that surface insights from your Lakehouse. + +### Coming soon + +- **Genie Plugin** — Conversational AI interface powered by Databricks Genie +- **Files Plugin** — Browse, upload, and manage files in Unity Catalog Volumes +- **Lakebase Plugin** — OLTP database operations with automatic OAuth token management +- ...and this is just the beginning. + +> Missing a plugin? [Open an issue](https://github.com/databricks/appkit/issues/new) and tell us what you need — community input directly shapes the roadmap. + ## Getting started Follow the [Getting Started](https://databricks.github.io/appkit/docs/) guide to get started with AppKit. diff --git a/apps/dev-playground/.env.dist b/apps/dev-playground/.env.dist index 7a7076ff..135da823 100644 --- a/apps/dev-playground/.env.dist +++ b/apps/dev-playground/.env.dist @@ -6,3 +6,8 @@ NODE_ENV='development' OTEL_EXPORTER_OTLP_ENDPOINT='http://localhost:4318' OTEL_RESOURCE_ATTRIBUTES='service.sample_attribute=dev' OTEL_SERVICE_NAME='dev-playground' +LAKEBASE_ENDPOINT='' # Run: databricks postgres list-endpoints projects/{project-id}/branches/{branch-id} — use the `name` field from the output +PGHOST= +PGUSER= +PGDATABASE=databricks_postgres +PGSSLMODE=require diff --git a/apps/dev-playground/client/package-lock.json b/apps/dev-playground/client/package-lock.json index a0cd6cd5..22b20739 100644 --- a/apps/dev-playground/client/package-lock.json +++ b/apps/dev-playground/client/package-lock.json @@ -113,6 +113,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2615,6 +2616,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.134.9.tgz", "integrity": "sha512-JIxFamShs3gRIkOxpgz/3bglbSKZHMrzKASwNFg+sQPVXVPOLtN35D5PuEDAFTPPht9Wv48WWUNYE03ZytnNug==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.133.28", "@tanstack/react-store": "^0.8.0", @@ -2722,6 +2724,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.134.9.tgz", "integrity": "sha512-9Vr8tYC59I70DYGVRknRf4vjQMjSfHvmc+iTM8vcpwERBh3Vgkv90f8ol85KHKqjorSsCqMeYFhFt8AM4A4CSw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.133.28", "@tanstack/store": "^0.8.0", @@ -3061,6 +3064,7 @@ "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -3071,6 +3075,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3081,6 +3086,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3144,6 +3150,7 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -3402,6 +3409,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3625,6 +3633,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3923,7 +3932,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -4223,6 +4233,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5590,6 +5601,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5678,6 +5690,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5687,6 +5700,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5706,6 +5720,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5874,7 +5889,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6041,6 +6057,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -6193,7 +6210,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -6222,7 +6240,8 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tiny-warning": { "version": "1.0.3", @@ -6268,6 +6287,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6364,6 +6384,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6653,6 +6674,7 @@ "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.1.14.tgz", "integrity": "sha512-eSiiRJmovt8qDJkGyZuLnbxAOAdie6NCmmd0NkTC0RJI9duiSBTfr8X2mBYJOUFzxQa2USaHmL99J9uMxkjCyw==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.92.0", "fdir": "^6.5.0", @@ -6745,6 +6767,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx b/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx new file mode 100644 index 00000000..10bb6406 --- /dev/null +++ b/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx @@ -0,0 +1,259 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@databricks/appkit-ui/react"; +import { Activity, Loader2 } from "lucide-react"; +import { useId, useState } from "react"; +import { useLakebaseData, useLakebasePost } from "@/hooks/use-lakebase-data"; + +interface ActivityLog { + id: number; + userId: string; + action: string; + metadata: Record | null; + timestamp: string; +} + +interface Stats { + totalLogs: number; + uniqueUsers: number; + recentActivity: number; +} + +export function ActivityLogsPanel() { + const userIdFieldId = useId(); + const actionFieldId = useId(); + + const { + data: logs, + loading: logsLoading, + error: logsError, + refetch, + } = useLakebaseData("/api/lakebase-examples/drizzle/activity"); + + const { data: stats } = useLakebaseData( + "/api/lakebase-examples/drizzle/stats", + ); + + const { post, loading: creating } = useLakebasePost< + Partial, + ActivityLog + >("/api/lakebase-examples/drizzle/activity"); + + const generateRandomActivity = () => { + const users = ["alice", "bob", "charlie", "diana", "eve"]; + const actions = [ + "login", + "logout", + "view_dashboard", + "create_report", + "export_data", + "update_settings", + "share_document", + "delete_item", + ]; + + return { + userId: users[Math.floor(Math.random() * users.length)], + action: actions[Math.floor(Math.random() * actions.length)], + }; + }; + + const [formData, setFormData] = useState(generateRandomActivity()); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await post({ + userId: formData.userId, + action: formData.action, + metadata: { + source: "web", + timestamp: new Date().toISOString(), + }, + }); + + if (result) { + setFormData(generateRandomActivity()); + refetch(); + } + }; + + return ( +
+ {/* Header */} + + +
+
+ +
+
+ Drizzle ORM Example + + Type-safe queries with schema definitions and automatic type + inference + +
+
+
+
+ + {/* Statistics */} + {stats && ( +
+ + + Total Logs + {stats.totalLogs} + + + + + + Unique Users + + {stats.uniqueUsers} + + + + + + Last 24 Hours + + {stats.recentActivity} + + +
+ )} + + {/* Create log form */} + + + Log Activity + + +
+
+
+ + + setFormData({ ...formData, userId: e.target.value }) + } + placeholder="alice" + required + /> +
+
+ + + setFormData({ ...formData, action: e.target.value }) + } + placeholder="view_dashboard" + required + /> +
+
+ +
+
+
+ + {/* Activity logs */} + + +
+ Activity Logs + +
+
+ + {logsLoading && ( +
+
+ Loading activity logs... +
+ )} + + {logsError && ( +
+ Error: {logsError.message} +
+ )} + + {logs && logs.length === 0 && ( +
+ +

No activity logs yet. Log your first activity above.

+
+ )} + + {logs && logs.length > 0 && ( +
+ {logs.map((log) => ( +
+
+
+ + {log.userId} + + {log.action} +
+ + {new Date(log.timestamp).toLocaleString()} + +
+ {log.metadata && ( +
+ + View metadata + +
+                        {JSON.stringify(log.metadata, null, 2)}
+                      
+
+ )} +
+ ))} +
+ )} + + +
+ ); +} diff --git a/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx b/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx new file mode 100644 index 00000000..8730bb04 --- /dev/null +++ b/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx @@ -0,0 +1,524 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@databricks/appkit-ui/react"; +import { + CheckCircle, + Circle, + Loader2, + Package, + ShoppingCart, + Truck, +} from "lucide-react"; +import { useId, useState } from "react"; +import { + useLakebaseData, + useLakebasePatch, + useLakebasePost, +} from "@/hooks/use-lakebase-data"; + +interface Order { + id: number; + orderNumber: string; + customerName: string; + productName: string; + amount: number; + status: "pending" | "processing" | "shipped" | "delivered"; + createdAt: string; + updatedAt: string; +} + +interface OrderStats { + total: number; + pending: number; + processing: number; + shipped: number; + delivered: number; +} + +export function OrdersPanel() { + const orderNumberId = useId(); + const customerNameId = useId(); + const productNameId = useId(); + const amountId = useId(); + + const { + data: orders, + loading: ordersLoading, + error: ordersError, + refetch, + } = useLakebaseData("/api/lakebase-examples/sequelize/orders"); + + const { data: stats } = useLakebaseData( + "/api/lakebase-examples/sequelize/stats", + ); + + const { post, loading: creating } = useLakebasePost, Order>( + "/api/lakebase-examples/sequelize/orders", + ); + + const { patch, loading: updating } = useLakebasePatch< + { status: string }, + Order + >("/api/lakebase-examples/sequelize/orders"); + + const generateRandomOrder = () => { + const customers = [ + "Alice Johnson", + "Bob Smith", + "Carol Williams", + "David Brown", + "Emma Davis", + "Frank Miller", + "Grace Wilson", + "Henry Moore", + ]; + const products = [ + "Wireless Bluetooth Headphones", + "USB-C Charging Cable", + "Laptop Stand - Ergonomic", + "Mechanical Keyboard - RGB", + "4K Webcam with Microphone", + "Portable SSD 1TB", + "Wireless Mouse - Ergonomic", + "Monitor Arm Mount", + "Noise Cancelling Earbuds", + "Desk Lamp - LED", + ]; + + const orderNum = `ORD-${new Date().getFullYear()}-${String(Math.floor(Math.random() * 9999) + 1).padStart(4, "0")}`; + const customer = customers[Math.floor(Math.random() * customers.length)]; + const product = products[Math.floor(Math.random() * products.length)]; + const amount = (Math.random() * 200 + 10).toFixed(2); + + return { + orderNumber: orderNum, + customerName: customer, + productName: product, + amount, + }; + }; + + const [formData, setFormData] = useState(generateRandomOrder()); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await post({ + orderNumber: formData.orderNumber, + customerName: formData.customerName, + productName: formData.productName, + amount: Number.parseFloat(formData.amount), + status: "pending", + }); + + if (result) { + setFormData(generateRandomOrder()); + refetch(); + } + }; + + const handleStatusUpdate = async (id: number, status: Order["status"]) => { + const result = await patch(id, { status }); + if (result) { + refetch(); + } + }; + + const getStatusBadge = (status: Order["status"]) => { + switch (status) { + case "pending": + return ( + + + Pending + + ); + case "processing": + return ( + + + Processing + + ); + case "shipped": + return ( + + + Shipped + + ); + case "delivered": + return ( + + + Delivered + + ); + } + }; + + const ordersByStatus = orders + ? { + pending: orders.filter((o) => o.status === "pending"), + processing: orders.filter((o) => o.status === "processing"), + shipped: orders.filter((o) => o.status === "shipped"), + delivered: orders.filter((o) => o.status === "delivered"), + } + : { pending: [], processing: [], shipped: [], delivered: [] }; + + return ( +
+ {/* Header */} + + +
+
+ +
+
+ Sequelize Example + + Model-based ORM with intuitive API and automatic timestamps + +
+
+
+
+ + {/* Statistics */} + {stats && ( +
+ + + + Total Orders + + {stats.total} + + + + + Pending + {stats.pending} + + + + + Processing + {stats.processing} + + + + + Shipped + {stats.shipped} + + + + + Delivered + {stats.delivered} + + +
+ )} + + {/* Create order form */} + + + Create Order + + +
+
+
+ + + setFormData({ ...formData, orderNumber: e.target.value }) + } + placeholder="ORD-2024-0001" + required + /> +
+
+ + + setFormData({ ...formData, customerName: e.target.value }) + } + placeholder="John Doe" + required + /> +
+
+
+
+ + + setFormData({ ...formData, productName: e.target.value }) + } + placeholder="Wireless Headphones" + required + /> +
+
+ + + setFormData({ ...formData, amount: e.target.value }) + } + placeholder="99.99" + required + /> +
+
+ +
+
+
+ + {/* Order board */} + + +
+ Order Board + +
+
+ + {ordersLoading && ( +
+
+ Loading orders... +
+ )} + + {ordersError && ( +
+ Error:{" "} + {ordersError.message} +
+ )} + + {orders && orders.length === 0 && ( +
+ +

No orders yet. Add an order to get started.

+
+ )} + + {orders && orders.length > 0 && ( +
+ {/* Pending column */} +
+
+ Pending ({ordersByStatus.pending.length}) +
+
+ {ordersByStatus.pending.map((order) => ( + +
+
{getStatusBadge(order.status)}
+

+ {order.orderNumber} +

+

+ {order.customerName} +

+

+ {order.productName} +

+

+ ${Number(order.amount).toFixed(2)} +

+ +
+
+ ))} +
+
+ + {/* Processing column */} +
+
+ Processing ({ordersByStatus.processing.length}) +
+
+ {ordersByStatus.processing.map((order) => ( + +
+
{getStatusBadge(order.status)}
+

+ {order.orderNumber} +

+

+ {order.customerName} +

+

+ {order.productName} +

+

+ ${Number(order.amount).toFixed(2)} +

+
+ + +
+
+
+ ))} +
+
+ + {/* Shipped column */} +
+
+ Shipped ({ordersByStatus.shipped.length}) +
+
+ {ordersByStatus.shipped.map((order) => ( + +
+
{getStatusBadge(order.status)}
+

+ {order.orderNumber} +

+

+ {order.customerName} +

+

+ {order.productName} +

+

+ ${Number(order.amount).toFixed(2)} +

+ +
+
+ ))} +
+
+ + {/* Delivered column */} +
+
+ Delivered ({ordersByStatus.delivered.length}) +
+
+ {ordersByStatus.delivered.map((order) => ( + +
+
{getStatusBadge(order.status)}
+

+ {order.orderNumber} +

+

+ {order.customerName} +

+

+ {order.productName} +

+

+ ${Number(order.amount).toFixed(2)} +

+
+
+ ))} +
+
+
+ )} + + +
+ ); +} diff --git a/apps/dev-playground/client/src/components/lakebase/ProductsPanel.tsx b/apps/dev-playground/client/src/components/lakebase/ProductsPanel.tsx new file mode 100644 index 00000000..d1b6c690 --- /dev/null +++ b/apps/dev-playground/client/src/components/lakebase/ProductsPanel.tsx @@ -0,0 +1,305 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@databricks/appkit-ui/react"; +import { Database, Loader2, Package } from "lucide-react"; +import { useId, useState } from "react"; +import { useLakebaseData, useLakebasePost } from "@/hooks/use-lakebase-data"; + +interface Product { + id: number; + name: string; + category: string; + price: number | string; // PostgreSQL DECIMAL returns as string + stock: number; + created_by?: string; + created_at: string; +} + +interface CreateProductRequest { + name: string; + category: string; + price: number; + stock: number; +} + +interface HealthStatus { + status: string; + connected: boolean; + message: string; +} + +export function ProductsPanel() { + const nameId = useId(); + const categoryId = useId(); + const priceId = useId(); + const stockId = useId(); + + const { + data: products, + loading: productsLoading, + error: productsError, + refetch, + } = useLakebaseData("/api/lakebase-examples/raw/products"); + + const { data: health } = useLakebaseData( + "/api/lakebase-examples/raw/health", + ); + + const { post, loading: creating } = useLakebasePost< + CreateProductRequest, + Product + >("/api/lakebase-examples/raw/products"); + + const generateRandomProduct = () => { + const products = [ + "Ergonomic Keyboard", + "Wireless Mouse", + "USB-C Hub", + "Laptop Stand", + "Monitor Arm", + "Mechanical Keyboard", + "Gaming Headset", + "Webcam HD", + ]; + const categories = ["Electronics", "Accessories", "Peripherals", "Office"]; + const price = (Math.random() * (199.99 - 29.99) + 29.99).toFixed(2); + const stock = Math.floor(Math.random() * (500 - 50) + 50); + + return { + name: products[Math.floor(Math.random() * products.length)], + category: categories[Math.floor(Math.random() * categories.length)], + price, + stock: String(stock), + }; + }; + + const [formData, setFormData] = useState(generateRandomProduct()); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await post({ + name: formData.name, + category: formData.category, + price: Number(formData.price), + stock: Number(formData.stock), + }); + + if (result) { + setFormData(generateRandomProduct()); + refetch(); + } + }; + + return ( +
+ {/* Header with connection status */} + + +
+
+
+ +
+
+ Raw Driver Example + + Direct PostgreSQL connection using pg.Pool with automatic + OAuth token refresh + +
+
+ {health && ( + + {health.connected ? "Connected" : "Disconnected"} + + )} +
+
+
+ + {/* Create product form */} + + + Create Product + + +
+
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="Wireless Mouse" + required + /> +
+
+ + + setFormData({ ...formData, category: e.target.value }) + } + placeholder="Electronics" + required + /> +
+
+ + + setFormData({ ...formData, price: e.target.value }) + } + placeholder="29.99" + required + /> +
+
+ + + setFormData({ ...formData, stock: e.target.value }) + } + placeholder="100" + required + /> +
+
+ +
+
+
+ + {/* Products list */} + + +
+ Products Catalog + +
+
+ + {productsLoading && ( +
+
+ Loading products... +
+ )} + + {productsError && ( +
+ Error:{" "} + {productsError.message} +
+ )} + + {products && products.length === 0 && ( +
+ +

No products available. Create your first product above.

+
+ )} + + {products && products.length > 0 && ( +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ ID + + Name + + Category + + Price + + Stock +
{product.id}{product.name} + {product.category} + + ${Number(product.price).toFixed(2)} + + {product.stock} +
+
+ )} + + +
+ ); +} diff --git a/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx b/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx new file mode 100644 index 00000000..fdc5c19a --- /dev/null +++ b/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx @@ -0,0 +1,401 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Textarea, +} from "@databricks/appkit-ui/react"; +import { CheckCircle, Circle, ListTodo, Loader2 } from "lucide-react"; +import { useId, useState } from "react"; +import { + useLakebaseData, + useLakebasePatch, + useLakebasePost, +} from "@/hooks/use-lakebase-data"; + +interface Task { + id: number; + title: string; + status: "pending" | "in_progress" | "completed"; + description: string | null; + createdAt: string; +} + +interface TaskStats { + total: number; + pending: number; + inProgress: number; + completed: number; +} + +export function TasksPanel() { + const titleId = useId(); + const descriptionId = useId(); + + const { + data: tasks, + loading: tasksLoading, + error: tasksError, + refetch, + } = useLakebaseData("/api/lakebase-examples/typeorm/tasks"); + + const { data: stats } = useLakebaseData( + "/api/lakebase-examples/typeorm/stats", + ); + + const { post, loading: creating } = useLakebasePost, Task>( + "/api/lakebase-examples/typeorm/tasks", + ); + + const { patch, loading: updating } = useLakebasePatch< + { status: string }, + Task + >("/api/lakebase-examples/typeorm/tasks"); + + const generateRandomTask = () => { + const tasks = [ + { + title: "Implement user authentication", + description: "Add OAuth2 authentication flow with JWT tokens", + }, + { + title: "Write API documentation", + description: "Document all REST endpoints with examples", + }, + { + title: "Set up CI/CD pipeline", + description: "Configure GitHub Actions for automated testing", + }, + { + title: "Add error monitoring", + description: "Integrate error tracking and alerting system", + }, + { + title: "Optimize database queries", + description: "Add indexes and analyze slow queries", + }, + { + title: "Implement data validation", + description: "Add schema validation for all API requests", + }, + { + title: "Set up development environment", + description: "Configure local development tools and dependencies", + }, + { + title: "Design database schema", + description: "Create ERD and define table relationships", + }, + ]; + + const task = tasks[Math.floor(Math.random() * tasks.length)]; + return { + title: task.title, + description: task.description, + }; + }; + + const [formData, setFormData] = useState(generateRandomTask()); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await post({ + title: formData.title, + description: formData.description || null, + status: "pending", + }); + + if (result) { + setFormData(generateRandomTask()); + refetch(); + } + }; + + const handleStatusUpdate = async (id: number, status: Task["status"]) => { + const result = await patch(id, { status }); + if (result) { + refetch(); + } + }; + + const getStatusBadge = (status: Task["status"]) => { + switch (status) { + case "pending": + return ( + + + Pending + + ); + case "in_progress": + return ( + + + In Progress + + ); + case "completed": + return ( + + + Completed + + ); + } + }; + + const tasksByStatus = tasks + ? { + pending: tasks.filter((t) => t.status === "pending"), + in_progress: tasks.filter((t) => t.status === "in_progress"), + completed: tasks.filter((t) => t.status === "completed"), + } + : { pending: [], in_progress: [], completed: [] }; + + return ( +
+ {/* Header */} + + +
+
+ +
+
+ TypeORM Example + + Entity-based data access with decorators and repository pattern + +
+
+
+
+ + {/* Statistics */} + {stats && ( +
+ + + Total Tasks + {stats.total} + + + + + Pending + {stats.pending} + + + + + In Progress + {stats.inProgress} + + + + + Completed + {stats.completed} + + +
+ )} + + {/* Create task form */} + + + Create Task + + +
+
+ + + setFormData({ ...formData, title: e.target.value }) + } + placeholder="Implement feature X" + required + /> +
+
+ +