| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 |
- import fs from 'fs/promises'
- import path from 'path'
- import { compact, sortBy } from 'lodash'
- import { v4 as uuidv4 } from 'uuid'
- import { z } from 'zod'
- import { generateDeterministicUuid } from './snippets.browser'
- import { SNIPPETS_DIR } from './snippets.constants'
- type DeepPartial<T> = T extends object
- ? {
- [P in keyof T]?: DeepPartial<T[P]>
- }
- : T
- export const SnippetSchema = z.object({
- id: z.string().uuid(),
- inserted_at: z.string().default(() => new Date().toISOString()),
- updated_at: z.string().default(() => new Date().toISOString()),
- type: z.literal('sql'),
- name: z.string(),
- description: z.string().optional(),
- favorite: z.boolean().default(false),
- content: z.object({
- sql: z.string(),
- content_id: z.string(),
- schema_version: z.literal('1.0'),
- }),
- visibility: z.union([
- z.literal('user'),
- z.literal('project'),
- z.literal('org'),
- z.literal('public'),
- ]),
- project_id: z.number().default(1),
- folder_id: z.string().nullable().default(null),
- owner_id: z.number().default(1),
- owner: z
- .object({
- id: z.number(),
- username: z.string(),
- })
- .default({ id: 1, username: 'johndoe' }),
- updated_by: z
- .object({
- id: z.number(),
- username: z.string(),
- })
- .default({ id: 1, username: 'johndoe' }),
- })
- export const FolderSchema = z.object({
- id: z.string(),
- name: z.string(),
- owner_id: z.number().default(1),
- parent_id: z.string().nullable(),
- project_id: z.number().default(1),
- })
- export type Snippet = z.infer<typeof SnippetSchema>
- export type Folder = z.infer<typeof FolderSchema>
- export type FilesystemEntry = {
- id: string
- name: string
- type: 'file' | 'folder'
- folderId: string | null
- content?: string // Only for files
- createdAt: Date
- }
- 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
- }
- const buildFolder = (name: string) => {
- const folder: Folder = {
- id: generateDeterministicUuid([name]),
- name: name,
- owner_id: 1,
- parent_id: null,
- project_id: 1,
- }
- return folder
- }
- const sanitizeName = (name: string): string => {
- // Remove path traversal sequences and normalize
- const sanitized = path.basename(name)
- if (sanitized !== name || name.includes('\0')) {
- throw new Error('Invalid name: path traversal or null bytes detected')
- }
- return sanitized
- }
- /**
- * Gets a complete snapshot of the filesystem structure including files and folders
- * @returns An array of files and folders with their metadata
- */
- export async function getFilesystemEntries(): Promise<FilesystemEntry[]> {
- if (SNIPPETS_DIR === '') {
- throw new Error(
- 'SNIPPETS_MANAGEMENT_FOLDER env var is not set. Please set it to use snippets properly.'
- )
- }
- // Ensure the snippets directory exists
- try {
- await fs.access(SNIPPETS_DIR)
- } catch {
- await fs.mkdir(SNIPPETS_DIR, { recursive: true })
- }
- const entries: FilesystemEntry[] = []
- const readEntriesRecursively = async (
- dirPath: string,
- folderName: string | null
- ): Promise<void> => {
- const items = await fs.readdir(dirPath, { withFileTypes: true })
- const folderId = folderName ? generateDeterministicUuid([folderName]) : null
- for (const item of items) {
- const itemPath = path.join(dirPath, item.name)
- if (item.isDirectory()) {
- // if the folder entry is under another folder, skip it. Subdirectories are not supported.
- if (folderName) {
- continue
- }
- const stats = await fs.stat(itemPath)
- // Add folder entry
- entries.push({
- id: generateDeterministicUuid([folderId, item.name]),
- name: item.name,
- type: 'folder',
- // Folders are always at root level in this implementation
- folderId: null,
- createdAt: stats.birthtime,
- })
- await readEntriesRecursively(itemPath, item.name)
- } else if (item.isFile() && item.name.endsWith('.sql')) {
- const [content, stats] = await Promise.all([
- fs.readFile(itemPath, 'utf-8'),
- fs.stat(itemPath),
- ])
- const snippetName = item.name.replace('.sql', '')
- entries.push({
- id: generateDeterministicUuid([folderId, `${snippetName}.sql`]),
- name: snippetName,
- type: 'file',
- folderId: folderId,
- content: content,
- createdAt: stats.birthtime,
- })
- }
- }
- }
- await readEntriesRecursively(SNIPPETS_DIR, null)
- return entries
- }
- export const getSnippet = async (snippetId: string) => {
- const entries = await getFilesystemEntries()
- const foundSnippet = entries.find((e) => e.type === 'file' && e.id === snippetId)
- if (!foundSnippet) {
- throw new Error(`Snippet with id ${snippetId} not found`)
- }
- return buildSnippet(
- foundSnippet.name,
- foundSnippet.content || '',
- foundSnippet.folderId,
- foundSnippet.createdAt
- )
- }
- /**
- * Gets a filtered paginated list of snippets based on the provided criteria
- */
- export const getSnippets = async ({
- searchTerm,
- limit,
- cursor,
- sort,
- sortOrder,
- folderId,
- }: {
- searchTerm?: string
- limit?: number
- cursor?: string
- sortOrder?: 'asc' | 'desc'
- sort?: 'name' | 'inserted_at'
- folderId?: string | null
- }): Promise<{ cursor: string | undefined; snippets: Snippet[] }> => {
- // Normalize and set default values
- const normalizedSearchTerm = searchTerm?.trim() ?? ''
- const normalizedLimit = limit ?? 100
- const normalizedSort = sort ?? 'inserted_at'
- const normalizedSortOrder = sortOrder ?? 'desc'
- const normalizedCursor = cursor ?? undefined
- const normalizedFolderId = folderId ?? null
- // Validate inputs
- if (normalizedLimit <= 0) {
- throw new Error('Limit must be a positive number')
- }
- if (normalizedLimit > 1000) {
- throw new Error('Limit cannot exceed 1000')
- }
- const entries = await getFilesystemEntries()
- const files = entries.filter(
- (entry): entry is FilesystemEntry & { type: 'file'; content: string } =>
- entry.type === 'file' && entry.content !== undefined && entry.content !== null
- )
- // Filter snippets based on search term or folder
- let filteredSnippets = files
- if (normalizedSearchTerm) {
- // When searching, look across all folders and support case-insensitive search
- filteredSnippets = files.filter((file) =>
- file.name.toLowerCase().includes(normalizedSearchTerm.toLowerCase())
- )
- } else {
- // Filter by specific folder or root (null)
- filteredSnippets = files.filter((file) => file.folderId === normalizedFolderId)
- }
- // Sort snippets
- const sortedSnippets = sortBy(filteredSnippets, (snippet) => {
- if (normalizedSort === 'inserted_at') {
- return snippet.createdAt.getTime()
- }
- return snippet.name.toLowerCase() // Case-insensitive name sorting
- })
- if (normalizedSortOrder === 'desc') {
- sortedSnippets.reverse()
- }
- // Apply cursor-based pagination
- let paginatedSnippets = sortedSnippets
- if (normalizedCursor) {
- const cursorIndex = sortedSnippets.findIndex((s) => s.id === normalizedCursor)
- if (cursorIndex !== -1) {
- paginatedSnippets = sortedSnippets.slice(cursorIndex + 1)
- }
- // If cursor not found, return all snippets (graceful degradation)
- }
- // Apply limit and determine next cursor
- let nextCursor: string | undefined = undefined
- let finalSnippets = paginatedSnippets
- if (normalizedLimit && paginatedSnippets.length > normalizedLimit) {
- finalSnippets = paginatedSnippets.slice(0, normalizedLimit)
- nextCursor = finalSnippets[finalSnippets.length - 1].id
- }
- return {
- cursor: nextCursor,
- snippets: finalSnippets.map((file) =>
- buildSnippet(file.name, file.content, file.folderId, file.createdAt)
- ),
- }
- }
- /**
- * Saves a snippet to the filesystem
- */
- export async function saveSnippet(snippet: Snippet): Promise<Snippet> {
- const entries = await getFilesystemEntries()
- const existingSnippet = entries.find((entry) => entry.id === snippet.id && entry.type === 'file')
- if (existingSnippet) {
- throw new Error(`Snippet with id ${snippet.id} already exists`)
- }
- // check if the folder exists
- if (snippet.folder_id !== null) {
- const existingFolder = entries.find(
- (entry) => entry.id === snippet.folder_id && entry.type === 'folder'
- )
- if (existingFolder === undefined) {
- throw new Error(`Folder with id ${snippet.folder_id} not found`)
- }
- }
- const snippetName = sanitizeName(snippet.name)
- const content = snippet.content.sql || ''
- const folderId = snippet.folder_id || null
- const folder = entries.find((f) => f.id === folderId && f.type === 'folder')
- const folderPath = folder ? path.join(SNIPPETS_DIR, folder.name) : SNIPPETS_DIR
- const filePath = path.join(folderPath, `${snippetName}.sql`)
- await fs.writeFile(filePath, content || '', 'utf-8')
- const stats = await fs.stat(filePath)
- const result = buildSnippet(snippetName, content, snippet.folder_id, stats.birthtime)
- return result
- }
- /**
- * Deletes a snippet from the filesystem
- */
- export async function deleteSnippet(id: string): Promise<void> {
- const entries = await getFilesystemEntries()
- const found = entries.find((entry) => entry.id === id && entry.type === 'file')
- if (!found) {
- throw new Error(`Snippet with id ${id} not found`)
- }
- const filename = `${found.name}.sql`
- const currentFolder = entries.find((f) => f.id === found.folderId && f.type === 'folder')
- const paths = compact([SNIPPETS_DIR, currentFolder?.name, filename])
- const filePath = path.join(...paths)
- try {
- await fs.unlink(filePath)
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
- throw error
- }
- }
- }
- /**
- * Updates a snippet in the filesystem. It also handles renaming and moving.
- */
- export async function updateSnippet(id: string, updates: DeepPartial<Snippet>): Promise<Snippet> {
- const entries = await getFilesystemEntries()
- const foundSnippet = entries
- .filter(
- (entry): entry is FilesystemEntry & { type: 'file'; content: string } => entry.type === 'file'
- )
- .find((s) => s.id === id)
- if (!foundSnippet) {
- throw new Error(`Snippet with id ${id} not found`)
- }
- const newId = generateDeterministicUuid([
- updates.folder_id !== undefined ? updates.folder_id : foundSnippet.folderId,
- `${updates.name ?? foundSnippet.name}.sql`,
- ])
- const snippetAtTargetLocation = entries.find(
- (entry) => entry.id === newId && entry.type === 'file'
- )
- if (snippetAtTargetLocation && snippetAtTargetLocation.id !== foundSnippet.id) {
- throw new Error(
- `Snippet named "${updates.name ?? foundSnippet.name}" already exists in the specified folder`
- )
- }
- const snippet = buildSnippet(
- foundSnippet.name,
- foundSnippet.content || '',
- foundSnippet.folderId,
- foundSnippet.createdAt
- )
- // it's easier to delete the old file first and then recreate a new one
- await deleteSnippet(snippet.id)
- const updatedSnippet = await saveSnippet({
- name: updates.name ?? snippet.name,
- content: updates.content ?? snippet.content,
- // folder_id can be null
- folder_id: updates.folder_id !== undefined ? updates.folder_id : snippet.folder_id,
- } as Snippet)
- return updatedSnippet
- }
- export const getFolders = async (folderId: string | null = null): Promise<Folder[]> => {
- const entries = await getFilesystemEntries()
- const folders = entries
- .filter(
- (entry): entry is FilesystemEntry & { type: 'folder' } =>
- entry.type === 'folder' && entry.folderId === folderId
- )
- .map((folder) => buildFolder(folder.name))
- return folders
- }
- /**
- * Creates a new folder as an actual directory
- */
- export async function createFolder(_folderName: string): Promise<Folder> {
- const folderName = sanitizeName(_folderName)
- const entries = await getFilesystemEntries()
- const existingFolder = entries.find((folder) => folder.name === folderName)
- if (existingFolder) {
- throw new Error(`Folder with name ${folderName} already exists`)
- }
- const folderPath = path.join(SNIPPETS_DIR, folderName)
- await fs.mkdir(folderPath, { recursive: true })
- const newFolder = buildFolder(folderName)
- return newFolder
- }
- /**
- * Deletes a folder directory from the filesystem
- * @throws {Error} If the folder doesn't exist
- */
- export async function deleteFolder(id: string): Promise<void> {
- const entries = await getFilesystemEntries()
- const folder = entries.find((f) => f.id === id && f.type === 'folder')
- if (!folder) {
- throw new Error(`Folder with id ${id} not found`)
- }
- const folderPath = path.join(SNIPPETS_DIR, folder.name)
- try {
- await fs.rm(folderPath, { recursive: true, force: true })
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
- throw error
- }
- // If folder doesn't exist, still throw the original error
- throw new Error(`Folder with id ${id} not found`)
- }
- }
|