From 8a9a9948a86a37a6b09d2ea0a3aa46c227ef5665 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:07:31 +0200 Subject: [PATCH] fix(studio): self-hosted folder listings return metadata only (#47403) --- apps/studio/lib/api/snippets.utils.test.ts | 56 ++++++++ apps/studio/lib/api/snippets.utils.ts | 124 +++++++++++------- .../platform/projects/[ref]/content/count.ts | 1 + .../projects/[ref]/content/folders/[id].ts | 3 + .../projects/[ref]/content/folders/index.ts | 3 + 5 files changed, 142 insertions(+), 45 deletions(-) diff --git a/apps/studio/lib/api/snippets.utils.test.ts b/apps/studio/lib/api/snippets.utils.test.ts index f218ada61ae..7cff947defc 100644 --- a/apps/studio/lib/api/snippets.utils.test.ts +++ b/apps/studio/lib/api/snippets.utils.test.ts @@ -238,6 +238,23 @@ describe('snippets.utils', () => { await expect(getFilesystemEntries()).rejects.toThrow('Permission denied') }) + + it('should skip reading file content when includeContent is false', async () => { + mockedFS.access.mockResolvedValue(undefined) + mockedFS.readdir.mockResolvedValue([ + { name: 'snippet1.sql', isDirectory: () => false, isFile: () => true }, + { name: 'snippet2.sql', isDirectory: () => false, isFile: () => true }, + ] as any) + mockedFS.stat.mockResolvedValue({ birthtime: new Date('2023-01-01') } as any) + + const entries = await getFilesystemEntries({ includeContent: false }) + + const files = entries.filter((e) => e.type === 'file') + expect(files).toHaveLength(2) + expect(files.every((f) => f.content === undefined)).toBe(true) + // Listings should never touch file contents off disk. + expect(mockedFS.readFile).not.toHaveBeenCalled() + }) }) describe('getSnippet', () => { @@ -658,6 +675,45 @@ describe('snippets.utils', () => { expect(result.snippets).toHaveLength(1) }) + + it('should omit content but keep metadata when includeContent is false', async () => { + mockedFS.access.mockResolvedValue(undefined) + mockedFS.readdir.mockResolvedValue([ + { name: 'test-snippet.sql', isDirectory: () => false, isFile: () => true }, + ] as any) + mockedFS.stat.mockResolvedValue({ birthtime: new Date('2023-01-01T10:00:00Z') } as any) + + const result = await getSnippets({ includeContent: false }) + + expect(result.snippets).toHaveLength(1) + const snippet = result.snippets[0] + + // Matches the Management API folder-listing contract: metadata, no SQL body. + expect(snippet).not.toHaveProperty('content') + expect(snippet).toMatchObject({ + name: 'test-snippet', + type: 'sql', + visibility: 'user', + folder_id: null, + }) + expect(snippet.id).toBeDefined() + // Listing must not read file contents off disk. + expect(mockedFS.readFile).not.toHaveBeenCalled() + }) + + it('searches by name without reading content when includeContent is false', async () => { + mockedFS.access.mockResolvedValue(undefined) + mockedFS.readdir.mockResolvedValue([ + { name: 'user-query.sql', isDirectory: () => false, isFile: () => true }, + { name: 'admin-report.sql', isDirectory: () => false, isFile: () => true }, + ] as any) + mockedFS.stat.mockResolvedValue({ birthtime: new Date('2023-01-01') } as any) + + const result = await getSnippets({ searchTerm: 'user', includeContent: false }) + + expect(result.snippets.map((s) => s.name)).toEqual(['user-query']) + expect(mockedFS.readFile).not.toHaveBeenCalled() + }) }) describe('saveSnippet', () => { diff --git a/apps/studio/lib/api/snippets.utils.ts b/apps/studio/lib/api/snippets.utils.ts index 133a7d63b3b..e0361847fa3 100644 --- a/apps/studio/lib/api/snippets.utils.ts +++ b/apps/studio/lib/api/snippets.utils.ts @@ -69,35 +69,46 @@ export type FilesystemEntry = { createdAt: Date } +/** + * Snippet without its SQL body — the shape returned for listings, matching the Management API + * contract (`GetUserContentFolderResponse.data.contents[]` has no `content`). The body is loaded + * on demand via the item endpoint (`getSnippet`). + */ +export type SnippetMetadata = Omit + +const buildSnippetMetadata = ( + filename: string, + folderId: string | null, + createdAt: Date +): SnippetMetadata => ({ + id: generateDeterministicUuid([folderId, `${filename}.sql`]), + inserted_at: createdAt.toISOString(), + updated_at: createdAt.toISOString(), + type: 'sql', + name: filename.replace('.sql', ''), + description: '', + favorite: false, + visibility: 'user', + project_id: 1, + folder_id: folderId, + owner_id: 1, + owner: { id: 1, username: 'johndoe' }, + updated_by: { id: 1, username: 'johndoe' }, +}) + const buildSnippet = ( filename: string, content: string, folderId: string | null, createdAt: Date -) => { - const snippet: Snippet = { - id: generateDeterministicUuid([folderId, `${filename}.sql`]), - inserted_at: createdAt.toISOString(), - updated_at: createdAt.toISOString(), - type: 'sql', - name: filename.replace('.sql', ''), - description: '', - favorite: false, - content: { - sql: content, - content_id: uuidv4(), - schema_version: '1.0', - }, - visibility: 'user', - project_id: 1, - folder_id: folderId, - owner_id: 1, - owner: { id: 1, username: 'johndoe' }, - updated_by: { id: 1, username: 'johndoe' }, - } - - return snippet -} +): Snippet => ({ + ...buildSnippetMetadata(filename, folderId, createdAt), + content: { + sql: content, + content_id: uuidv4(), + schema_version: '1.0', + }, +}) const buildFolder = (name: string) => { const folder: Folder = { @@ -121,10 +132,14 @@ const sanitizeName = (name: string): string => { } /** - * Gets a complete snapshot of the filesystem structure including files and folders + * Gets a snapshot of the filesystem structure including files and folders. + * Pass `includeContent: false` for listings (sidebar/folder tree), which only need metadata — + * this skips reading every `.sql` file off disk. * @returns An array of files and folders with their metadata */ -export async function getFilesystemEntries(): Promise { +export async function getFilesystemEntries({ + includeContent = true, +}: { includeContent?: boolean } = {}): Promise { if (SNIPPETS_DIR === '') { throw new Error( 'SNIPPETS_MANAGEMENT_FOLDER env var is not set. Please set it to use snippets properly.' @@ -171,7 +186,7 @@ export async function getFilesystemEntries(): Promise { await readEntriesRecursively(itemPath, item.name) } else if (item.isFile() && item.name.endsWith('.sql')) { const [content, stats] = await Promise.all([ - fs.readFile(itemPath, 'utf-8'), + includeContent ? fs.readFile(itemPath, 'utf-8') : Promise.resolve(undefined), fs.stat(itemPath), ]) const snippetName = item.name.replace('.sql', '') @@ -181,7 +196,7 @@ export async function getFilesystemEntries(): Promise { name: snippetName, type: 'file', folderId: folderId, - content: content, + content, createdAt: stats.birthtime, }) } @@ -208,24 +223,42 @@ export const getSnippet = async (snippetId: string) => { ) } -/** - * Gets a filtered paginated list of snippets based on the provided criteria - */ -export const getSnippets = async ({ - searchTerm, - limit, - cursor, - sort, - sortOrder, - folderId, -}: { +interface GetSnippetsParams { searchTerm?: string limit?: number cursor?: string sortOrder?: 'asc' | 'desc' sort?: 'name' | 'inserted_at' folderId?: string | null -}): Promise<{ cursor: string | undefined; snippets: Snippet[] }> => { + /** + * Listings (sidebar/folder tree) only need metadata; the SQL body is loaded on demand via the + * item endpoint. Pass `false` to omit `content` and skip reading files off disk. Defaults to + * `true` for the flat `/content` endpoint, which the contract returns with content. + */ + includeContent?: boolean +} + +/** + * Gets a filtered paginated list of snippets based on the provided criteria + */ +export function getSnippets( + params: GetSnippetsParams & { includeContent: false } +): Promise<{ cursor: string | undefined; snippets: SnippetMetadata[] }> +export function getSnippets( + params?: GetSnippetsParams & { includeContent?: true } +): Promise<{ cursor: string | undefined; snippets: Snippet[] }> +export async function getSnippets({ + searchTerm, + limit, + cursor, + sort, + sortOrder, + folderId, + includeContent = true, +}: GetSnippetsParams = {}): Promise<{ + cursor: string | undefined + snippets: Snippet[] | SnippetMetadata[] +}> { // Normalize and set default values const normalizedSearchTerm = searchTerm?.trim() ?? '' const normalizedLimit = limit ?? 100 @@ -242,10 +275,9 @@ export const getSnippets = async ({ throw new Error('Limit cannot exceed 1000') } - const entries = await getFilesystemEntries() + const entries = await getFilesystemEntries({ includeContent }) const files = entries.filter( - (entry): entry is FilesystemEntry & { type: 'file'; content: string } => - entry.type === 'file' && entry.content !== undefined && entry.content !== null + (entry): entry is FilesystemEntry & { type: 'file' } => entry.type === 'file' ) // Filter snippets based on search term or folder @@ -294,7 +326,9 @@ export const getSnippets = async ({ return { cursor: nextCursor, snippets: finalSnippets.map((file) => - buildSnippet(file.name, file.content, file.folderId, file.createdAt) + includeContent + ? buildSnippet(file.name, file.content ?? '', file.folderId, file.createdAt) + : buildSnippetMetadata(file.name, file.folderId, file.createdAt) ), } } @@ -410,7 +444,7 @@ export async function updateSnippet(id: string, updates: DeepPartial): } export const getFolders = async (folderId: string | null = null): Promise => { - const entries = await getFilesystemEntries() + const entries = await getFilesystemEntries({ includeContent: false }) const folders = entries .filter( (entry): entry is FilesystemEntry & { type: 'folder' } => diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/count.ts b/apps/studio/pages/api/platform/projects/[ref]/content/count.ts index 0edde385c5d..dc3c09f68a0 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/count.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/count.ts @@ -26,6 +26,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { try { const { snippets } = await getSnippets({ searchTerm: params?.name, + includeContent: false, }) if (params?.name) { return res.status(200).json({ diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts b/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts index ed8564c1111..ac599cef62b 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/folders/[id].ts @@ -30,6 +30,8 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse