snippets.utils.ts 13 KB

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