mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(studio): self-hosted folder listings return metadata only (#47403)
This commit is contained in:
1 parent
fd85c81e6c
commit
8a9a9948a8
5 files changed
+142
-45
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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<Snippet, 'content'>
|
||||
|
||||
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<FilesystemEntry[]> {
|
||||
export async function getFilesystemEntries({
|
||||
includeContent = true,
|
||||
}: { includeContent?: boolean } = {}): Promise<FilesystemEntry[]> {
|
||||
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<FilesystemEntry[]> {
|
||||
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<FilesystemEntry[]> {
|
||||
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<Snippet>):
|
||||
}
|
||||
|
||||
export const getFolders = async (folderId: string | null = null): Promise<Folder[]> => {
|
||||
const entries = await getFilesystemEntries()
|
||||
const entries = await getFilesystemEntries({ includeContent: false })
|
||||
const folders = entries
|
||||
.filter(
|
||||
(entry): entry is FilesystemEntry & { type: 'folder' } =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -30,6 +30,8 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<GetRespons
|
||||
const folderId = (req.query.id as string) ?? null
|
||||
|
||||
const folders = await getFolders(folderId)
|
||||
// Folder listings return metadata only (no SQL body) to match the Management API contract; the
|
||||
// editor loads each snippet's content on demand via the item endpoint.
|
||||
const { cursor, snippets } = await getSnippets({
|
||||
searchTerm: params?.name,
|
||||
cursor: params?.cursor,
|
||||
@@ -37,6 +39,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<GetRespons
|
||||
limit: params?.limit ? Number(params.limit) : undefined,
|
||||
sort: params?.sort_by,
|
||||
sortOrder: params?.sort_order,
|
||||
includeContent: false,
|
||||
})
|
||||
|
||||
return res.status(200).json({ data: { folders: folders, contents: snippets }, cursor })
|
||||
|
||||
@@ -31,12 +31,15 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<GetRespons
|
||||
const params = req.query as GetRequestData
|
||||
|
||||
const folders = await getFolders()
|
||||
// Folder listings return metadata only (no SQL body) to match the Management API contract; the
|
||||
// editor loads each snippet's content on demand via the item endpoint.
|
||||
const { cursor, snippets } = await getSnippets({
|
||||
searchTerm: params?.name,
|
||||
limit: params?.limit ? Number(params.limit) : undefined,
|
||||
cursor: params?.cursor,
|
||||
sort: params?.sort_by,
|
||||
sortOrder: params?.sort_order,
|
||||
includeContent: false,
|
||||
})
|
||||
|
||||
res.status(200).json({ data: { folders, contents: snippets }, cursor })
|
||||
|
||||
Reference in new issue
Block a user