snippets.utils.ts466 lines · main
| 1 | import fs from 'fs/promises' |
| 2 | import path from 'path' |
| 3 | import { compact, sortBy } from 'lodash' |
| 4 | import { v4 as uuidv4 } from 'uuid' |
| 5 | import { z } from 'zod' |
| 6 | |
| 7 | import { generateDeterministicUuid } from './snippets.browser' |
| 8 | import { SNIPPETS_DIR } from './snippets.constants' |
| 9 | |
| 10 | type DeepPartial<T> = T extends object |
| 11 | ? { |
| 12 | [P in keyof T]?: DeepPartial<T[P]> |
| 13 | } |
| 14 | : T |
| 15 | |
| 16 | export const SnippetSchema = z.object({ |
| 17 | id: z.string().uuid(), |
| 18 | inserted_at: z.string().default(() => new Date().toISOString()), |
| 19 | updated_at: z.string().default(() => new Date().toISOString()), |
| 20 | type: z.literal('sql'), |
| 21 | name: z.string(), |
| 22 | description: z.string().optional(), |
| 23 | favorite: z.boolean().default(false), |
| 24 | content: z.object({ |
| 25 | sql: z.string(), |
| 26 | content_id: z.string(), |
| 27 | schema_version: z.literal('1.0'), |
| 28 | }), |
| 29 | visibility: z.union([ |
| 30 | z.literal('user'), |
| 31 | z.literal('project'), |
| 32 | z.literal('org'), |
| 33 | z.literal('public'), |
| 34 | ]), |
| 35 | project_id: z.number().default(1), |
| 36 | folder_id: z.string().nullable().default(null), |
| 37 | owner_id: z.number().default(1), |
| 38 | owner: z |
| 39 | .object({ |
| 40 | id: z.number(), |
| 41 | username: z.string(), |
| 42 | }) |
| 43 | .default({ id: 1, username: 'johndoe' }), |
| 44 | updated_by: z |
| 45 | .object({ |
| 46 | id: z.number(), |
| 47 | username: z.string(), |
| 48 | }) |
| 49 | .default({ id: 1, username: 'johndoe' }), |
| 50 | }) |
| 51 | |
| 52 | export const FolderSchema = z.object({ |
| 53 | id: z.string(), |
| 54 | name: z.string(), |
| 55 | owner_id: z.number().default(1), |
| 56 | parent_id: z.string().nullable(), |
| 57 | project_id: z.number().default(1), |
| 58 | }) |
| 59 | |
| 60 | export type Snippet = z.infer<typeof SnippetSchema> |
| 61 | export type Folder = z.infer<typeof FolderSchema> |
| 62 | |
| 63 | export type FilesystemEntry = { |
| 64 | id: string |
| 65 | name: string |
| 66 | type: 'file' | 'folder' |
| 67 | folderId: string | null |
| 68 | content?: string // Only for files |
| 69 | createdAt: Date |
| 70 | } |
| 71 | |
| 72 | const buildSnippet = ( |
| 73 | filename: string, |
| 74 | content: string, |
| 75 | folderId: string | null, |
| 76 | createdAt: Date |
| 77 | ) => { |
| 78 | const snippet: Snippet = { |
| 79 | id: generateDeterministicUuid([folderId, `${filename}.sql`]), |
| 80 | inserted_at: createdAt.toISOString(), |
| 81 | updated_at: createdAt.toISOString(), |
| 82 | type: 'sql', |
| 83 | name: filename.replace('.sql', ''), |
| 84 | description: '', |
| 85 | favorite: false, |
| 86 | content: { |
| 87 | sql: content, |
| 88 | content_id: uuidv4(), |
| 89 | schema_version: '1.0', |
| 90 | }, |
| 91 | visibility: 'user', |
| 92 | project_id: 1, |
| 93 | folder_id: folderId, |
| 94 | owner_id: 1, |
| 95 | owner: { id: 1, username: 'johndoe' }, |
| 96 | updated_by: { id: 1, username: 'johndoe' }, |
| 97 | } |
| 98 | |
| 99 | return snippet |
| 100 | } |
| 101 | |
| 102 | const buildFolder = (name: string) => { |
| 103 | const folder: Folder = { |
| 104 | id: generateDeterministicUuid([name]), |
| 105 | name: name, |
| 106 | owner_id: 1, |
| 107 | parent_id: null, |
| 108 | project_id: 1, |
| 109 | } |
| 110 | |
| 111 | return folder |
| 112 | } |
| 113 | |
| 114 | const sanitizeName = (name: string): string => { |
| 115 | // Remove path traversal sequences and normalize |
| 116 | const sanitized = path.basename(name) |
| 117 | if (sanitized !== name || name.includes('\0')) { |
| 118 | throw new Error('Invalid name: path traversal or null bytes detected') |
| 119 | } |
| 120 | return sanitized |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Gets a complete snapshot of the filesystem structure including files and folders |
| 125 | * @returns An array of files and folders with their metadata |
| 126 | */ |
| 127 | export async function getFilesystemEntries(): Promise<FilesystemEntry[]> { |
| 128 | if (SNIPPETS_DIR === '') { |
| 129 | throw new Error( |
| 130 | 'SNIPPETS_MANAGEMENT_FOLDER env var is not set. Please set it to use snippets properly.' |
| 131 | ) |
| 132 | } |
| 133 | |
| 134 | // Ensure the snippets directory exists |
| 135 | try { |
| 136 | await fs.access(SNIPPETS_DIR) |
| 137 | } catch { |
| 138 | await fs.mkdir(SNIPPETS_DIR, { recursive: true }) |
| 139 | } |
| 140 | |
| 141 | const entries: FilesystemEntry[] = [] |
| 142 | |
| 143 | const readEntriesRecursively = async ( |
| 144 | dirPath: string, |
| 145 | folderName: string | null |
| 146 | ): Promise<void> => { |
| 147 | const items = await fs.readdir(dirPath, { withFileTypes: true }) |
| 148 | const folderId = folderName ? generateDeterministicUuid([folderName]) : null |
| 149 | |
| 150 | for (const item of items) { |
| 151 | const itemPath = path.join(dirPath, item.name) |
| 152 | |
| 153 | if (item.isDirectory()) { |
| 154 | // if the folder entry is under another folder, skip it. Subdirectories are not supported. |
| 155 | if (folderName) { |
| 156 | continue |
| 157 | } |
| 158 | |
| 159 | const stats = await fs.stat(itemPath) |
| 160 | |
| 161 | // Add folder entry |
| 162 | entries.push({ |
| 163 | id: generateDeterministicUuid([folderId, item.name]), |
| 164 | name: item.name, |
| 165 | type: 'folder', |
| 166 | // Folders are always at root level in this implementation |
| 167 | folderId: null, |
| 168 | createdAt: stats.birthtime, |
| 169 | }) |
| 170 | |
| 171 | await readEntriesRecursively(itemPath, item.name) |
| 172 | } else if (item.isFile() && item.name.endsWith('.sql')) { |
| 173 | const [content, stats] = await Promise.all([ |
| 174 | fs.readFile(itemPath, 'utf-8'), |
| 175 | fs.stat(itemPath), |
| 176 | ]) |
| 177 | const snippetName = item.name.replace('.sql', '') |
| 178 | |
| 179 | entries.push({ |
| 180 | id: generateDeterministicUuid([folderId, `${snippetName}.sql`]), |
| 181 | name: snippetName, |
| 182 | type: 'file', |
| 183 | folderId: folderId, |
| 184 | content: content, |
| 185 | createdAt: stats.birthtime, |
| 186 | }) |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | await readEntriesRecursively(SNIPPETS_DIR, null) |
| 192 | return entries |
| 193 | } |
| 194 | |
| 195 | export const getSnippet = async (snippetId: string) => { |
| 196 | const entries = await getFilesystemEntries() |
| 197 | const foundSnippet = entries.find((e) => e.type === 'file' && e.id === snippetId) |
| 198 | |
| 199 | if (!foundSnippet) { |
| 200 | throw new Error(`Snippet with id ${snippetId} not found`) |
| 201 | } |
| 202 | |
| 203 | return buildSnippet( |
| 204 | foundSnippet.name, |
| 205 | foundSnippet.content || '', |
| 206 | foundSnippet.folderId, |
| 207 | foundSnippet.createdAt |
| 208 | ) |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Gets a filtered paginated list of snippets based on the provided criteria |
| 213 | */ |
| 214 | export const getSnippets = async ({ |
| 215 | searchTerm, |
| 216 | limit, |
| 217 | cursor, |
| 218 | sort, |
| 219 | sortOrder, |
| 220 | folderId, |
| 221 | }: { |
| 222 | searchTerm?: string |
| 223 | limit?: number |
| 224 | cursor?: string |
| 225 | sortOrder?: 'asc' | 'desc' |
| 226 | sort?: 'name' | 'inserted_at' |
| 227 | folderId?: string | null |
| 228 | }): Promise<{ cursor: string | undefined; snippets: Snippet[] }> => { |
| 229 | // Normalize and set default values |
| 230 | const normalizedSearchTerm = searchTerm?.trim() ?? '' |
| 231 | const normalizedLimit = limit ?? 100 |
| 232 | const normalizedSort = sort ?? 'inserted_at' |
| 233 | const normalizedSortOrder = sortOrder ?? 'desc' |
| 234 | const normalizedCursor = cursor ?? undefined |
| 235 | const normalizedFolderId = folderId ?? null |
| 236 | |
| 237 | // Validate inputs |
| 238 | if (normalizedLimit <= 0) { |
| 239 | throw new Error('Limit must be a positive number') |
| 240 | } |
| 241 | if (normalizedLimit > 1000) { |
| 242 | throw new Error('Limit cannot exceed 1000') |
| 243 | } |
| 244 | |
| 245 | const entries = await getFilesystemEntries() |
| 246 | const files = entries.filter( |
| 247 | (entry): entry is FilesystemEntry & { type: 'file'; content: string } => |
| 248 | entry.type === 'file' && entry.content !== undefined && entry.content !== null |
| 249 | ) |
| 250 | |
| 251 | // Filter snippets based on search term or folder |
| 252 | let filteredSnippets = files |
| 253 | if (normalizedSearchTerm) { |
| 254 | // When searching, look across all folders and support case-insensitive search |
| 255 | filteredSnippets = files.filter((file) => |
| 256 | file.name.toLowerCase().includes(normalizedSearchTerm.toLowerCase()) |
| 257 | ) |
| 258 | } else { |
| 259 | // Filter by specific folder or root (null) |
| 260 | filteredSnippets = files.filter((file) => file.folderId === normalizedFolderId) |
| 261 | } |
| 262 | |
| 263 | // Sort snippets |
| 264 | const sortedSnippets = sortBy(filteredSnippets, (snippet) => { |
| 265 | if (normalizedSort === 'inserted_at') { |
| 266 | return snippet.createdAt.getTime() |
| 267 | } |
| 268 | return snippet.name.toLowerCase() // Case-insensitive name sorting |
| 269 | }) |
| 270 | |
| 271 | if (normalizedSortOrder === 'desc') { |
| 272 | sortedSnippets.reverse() |
| 273 | } |
| 274 | |
| 275 | // Apply cursor-based pagination |
| 276 | let paginatedSnippets = sortedSnippets |
| 277 | if (normalizedCursor) { |
| 278 | const cursorIndex = sortedSnippets.findIndex((s) => s.id === normalizedCursor) |
| 279 | if (cursorIndex !== -1) { |
| 280 | paginatedSnippets = sortedSnippets.slice(cursorIndex + 1) |
| 281 | } |
| 282 | // If cursor not found, return all snippets (graceful degradation) |
| 283 | } |
| 284 | |
| 285 | // Apply limit and determine next cursor |
| 286 | let nextCursor: string | undefined = undefined |
| 287 | let finalSnippets = paginatedSnippets |
| 288 | |
| 289 | if (normalizedLimit && paginatedSnippets.length > normalizedLimit) { |
| 290 | finalSnippets = paginatedSnippets.slice(0, normalizedLimit) |
| 291 | nextCursor = finalSnippets[finalSnippets.length - 1].id |
| 292 | } |
| 293 | |
| 294 | return { |
| 295 | cursor: nextCursor, |
| 296 | snippets: finalSnippets.map((file) => |
| 297 | buildSnippet(file.name, file.content, file.folderId, file.createdAt) |
| 298 | ), |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | /** |
| 303 | * Saves a snippet to the filesystem |
| 304 | */ |
| 305 | export async function saveSnippet(snippet: Snippet): Promise<Snippet> { |
| 306 | const entries = await getFilesystemEntries() |
| 307 | const existingSnippet = entries.find((entry) => entry.id === snippet.id && entry.type === 'file') |
| 308 | |
| 309 | if (existingSnippet) { |
| 310 | throw new Error(`Snippet with id ${snippet.id} already exists`) |
| 311 | } |
| 312 | |
| 313 | // check if the folder exists |
| 314 | if (snippet.folder_id !== null) { |
| 315 | const existingFolder = entries.find( |
| 316 | (entry) => entry.id === snippet.folder_id && entry.type === 'folder' |
| 317 | ) |
| 318 | if (existingFolder === undefined) { |
| 319 | throw new Error(`Folder with id ${snippet.folder_id} not found`) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | const snippetName = sanitizeName(snippet.name) |
| 324 | const content = snippet.content.sql || '' |
| 325 | const folderId = snippet.folder_id || null |
| 326 | const folder = entries.find((f) => f.id === folderId && f.type === 'folder') |
| 327 | |
| 328 | const folderPath = folder ? path.join(SNIPPETS_DIR, folder.name) : SNIPPETS_DIR |
| 329 | const filePath = path.join(folderPath, `${snippetName}.sql`) |
| 330 | await fs.writeFile(filePath, content || '', 'utf-8') |
| 331 | const stats = await fs.stat(filePath) |
| 332 | |
| 333 | const result = buildSnippet(snippetName, content, snippet.folder_id, stats.birthtime) |
| 334 | return result |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Deletes a snippet from the filesystem |
| 339 | */ |
| 340 | export async function deleteSnippet(id: string): Promise<void> { |
| 341 | const entries = await getFilesystemEntries() |
| 342 | const found = entries.find((entry) => entry.id === id && entry.type === 'file') |
| 343 | |
| 344 | if (!found) { |
| 345 | throw new Error(`Snippet with id ${id} not found`) |
| 346 | } |
| 347 | |
| 348 | const filename = `${found.name}.sql` |
| 349 | const currentFolder = entries.find((f) => f.id === found.folderId && f.type === 'folder') |
| 350 | const paths = compact([SNIPPETS_DIR, currentFolder?.name, filename]) |
| 351 | const filePath = path.join(...paths) |
| 352 | |
| 353 | try { |
| 354 | await fs.unlink(filePath) |
| 355 | } catch (error) { |
| 356 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| 357 | throw error |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | /** |
| 363 | * Updates a snippet in the filesystem. It also handles renaming and moving. |
| 364 | */ |
| 365 | export async function updateSnippet(id: string, updates: DeepPartial<Snippet>): Promise<Snippet> { |
| 366 | const entries = await getFilesystemEntries() |
| 367 | const foundSnippet = entries |
| 368 | .filter( |
| 369 | (entry): entry is FilesystemEntry & { type: 'file'; content: string } => entry.type === 'file' |
| 370 | ) |
| 371 | .find((s) => s.id === id) |
| 372 | |
| 373 | if (!foundSnippet) { |
| 374 | throw new Error(`Snippet with id ${id} not found`) |
| 375 | } |
| 376 | |
| 377 | const newId = generateDeterministicUuid([ |
| 378 | updates.folder_id !== undefined ? updates.folder_id : foundSnippet.folderId, |
| 379 | `${updates.name ?? foundSnippet.name}.sql`, |
| 380 | ]) |
| 381 | |
| 382 | const snippetAtTargetLocation = entries.find( |
| 383 | (entry) => entry.id === newId && entry.type === 'file' |
| 384 | ) |
| 385 | |
| 386 | if (snippetAtTargetLocation && snippetAtTargetLocation.id !== foundSnippet.id) { |
| 387 | throw new Error( |
| 388 | `Snippet named "${updates.name ?? foundSnippet.name}" already exists in the specified folder` |
| 389 | ) |
| 390 | } |
| 391 | |
| 392 | const snippet = buildSnippet( |
| 393 | foundSnippet.name, |
| 394 | foundSnippet.content || '', |
| 395 | foundSnippet.folderId, |
| 396 | foundSnippet.createdAt |
| 397 | ) |
| 398 | |
| 399 | // it's easier to delete the old file first and then recreate a new one |
| 400 | await deleteSnippet(snippet.id) |
| 401 | |
| 402 | const updatedSnippet = await saveSnippet({ |
| 403 | name: updates.name ?? snippet.name, |
| 404 | content: updates.content ?? snippet.content, |
| 405 | // folder_id can be null |
| 406 | folder_id: updates.folder_id !== undefined ? updates.folder_id : snippet.folder_id, |
| 407 | } as Snippet) |
| 408 | |
| 409 | return updatedSnippet |
| 410 | } |
| 411 | |
| 412 | export const getFolders = async (folderId: string | null = null): Promise<Folder[]> => { |
| 413 | const entries = await getFilesystemEntries() |
| 414 | const folders = entries |
| 415 | .filter( |
| 416 | (entry): entry is FilesystemEntry & { type: 'folder' } => |
| 417 | entry.type === 'folder' && entry.folderId === folderId |
| 418 | ) |
| 419 | .map((folder) => buildFolder(folder.name)) |
| 420 | return folders |
| 421 | } |
| 422 | |
| 423 | /** |
| 424 | * Creates a new folder as an actual directory |
| 425 | */ |
| 426 | export async function createFolder(_folderName: string): Promise<Folder> { |
| 427 | const folderName = sanitizeName(_folderName) |
| 428 | |
| 429 | const entries = await getFilesystemEntries() |
| 430 | const existingFolder = entries.find((folder) => folder.name === folderName) |
| 431 | |
| 432 | if (existingFolder) { |
| 433 | throw new Error(`Folder with name ${folderName} already exists`) |
| 434 | } |
| 435 | |
| 436 | const folderPath = path.join(SNIPPETS_DIR, folderName) |
| 437 | |
| 438 | await fs.mkdir(folderPath, { recursive: true }) |
| 439 | const newFolder = buildFolder(folderName) |
| 440 | |
| 441 | return newFolder |
| 442 | } |
| 443 | |
| 444 | /** |
| 445 | * Deletes a folder directory from the filesystem |
| 446 | * @throws {Error} If the folder doesn't exist |
| 447 | */ |
| 448 | export async function deleteFolder(id: string): Promise<void> { |
| 449 | const entries = await getFilesystemEntries() |
| 450 | const folder = entries.find((f) => f.id === id && f.type === 'folder') |
| 451 | |
| 452 | if (!folder) { |
| 453 | throw new Error(`Folder with id ${id} not found`) |
| 454 | } |
| 455 | |
| 456 | const folderPath = path.join(SNIPPETS_DIR, folder.name) |
| 457 | try { |
| 458 | await fs.rm(folderPath, { recursive: true, force: true }) |
| 459 | } catch (error) { |
| 460 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| 461 | throw error |
| 462 | } |
| 463 | // If folder doesn't exist, still throw the original error |
| 464 | throw new Error(`Folder with id ${id} not found`) |
| 465 | } |
| 466 | } |