diff options
| author | MohamedBassem <me@mbassem.com> | 2024-10-12 16:47:22 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-10-12 17:37:42 +0000 |
| commit | c16173ea0fdbf6cc47b13756c0a77e8399669055 (patch) | |
| tree | 6b3ecd073259176059386eb16c6635e4699d26a3 | |
| parent | 9f87207d668fbe0a2039c63803128fbe5916f993 (diff) | |
| download | karakeep-c16173ea0fdbf6cc47b13756c0a77e8399669055.tar.zst | |
feature: Introduce a mechanism to cleanup dangling assets
| -rw-r--r-- | apps/web/components/dashboard/admin/AdminActions.tsx | 22 | ||||
| -rw-r--r-- | apps/web/components/dashboard/admin/ServerStats.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/dashboard/admin/UserList.tsx | 18 | ||||
| -rw-r--r-- | apps/workers/index.ts | 12 | ||||
| -rw-r--r-- | apps/workers/tidyAssetsWorker.ts | 107 | ||||
| -rw-r--r-- | packages/shared/assetdb.ts | 42 | ||||
| -rw-r--r-- | packages/shared/package.json | 1 | ||||
| -rw-r--r-- | packages/shared/queues.ts | 16 | ||||
| -rw-r--r-- | packages/trpc/routers/admin.ts | 65 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 70 |
10 files changed, 351 insertions, 8 deletions
diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/dashboard/admin/AdminActions.tsx index dfdf65eb..e5d6ed69 100644 --- a/apps/web/components/dashboard/admin/AdminActions.tsx +++ b/apps/web/components/dashboard/admin/AdminActions.tsx @@ -52,6 +52,21 @@ export default function AdminActions() { }, }); + const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } = + api.admin.tidyAssets.useMutation({ + onSuccess: () => { + toast({ + description: "Tidy assets request has been enqueued!", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + return ( <div> <div className="mb-2 mt-8 text-xl font-medium">Actions</div> @@ -97,6 +112,13 @@ export default function AdminActions() { > Reindex All Bookmarks </ActionButton> + <ActionButton + variant="destructive" + loading={isTidyAssetsPending} + onClick={() => tidyAssets()} + > + Compact Assets + </ActionButton> </div> </div> ); diff --git a/apps/web/components/dashboard/admin/ServerStats.tsx b/apps/web/components/dashboard/admin/ServerStats.tsx index e95dc437..f45d86c5 100644 --- a/apps/web/components/dashboard/admin/ServerStats.tsx +++ b/apps/web/components/dashboard/admin/ServerStats.tsx @@ -122,6 +122,12 @@ export default function ServerStats() { <TableCell>{serverStats.inferenceStats.pending}</TableCell> <TableCell>{serverStats.inferenceStats.failed}</TableCell> </TableRow> + <TableRow> + <TableCell>Tidy Assets Jobs</TableCell> + <TableCell>{serverStats.tidyAssetsStats.queued}</TableCell> + <TableCell>-</TableCell> + <TableCell>-</TableCell> + </TableRow> </TableBody> </Table> </div> diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/dashboard/admin/UserList.tsx index 024325a3..65bf4068 100644 --- a/apps/web/components/dashboard/admin/UserList.tsx +++ b/apps/web/components/dashboard/admin/UserList.tsx @@ -15,10 +15,18 @@ import { api } from "@/lib/trpc"; import { Trash } from "lucide-react"; import { useSession } from "next-auth/react"; +function toHumanReadableSize(size: number) { + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + if (size === 0) return "0 Bytes"; + const i = Math.floor(Math.log(size) / Math.log(1024)); + return (size / Math.pow(1024, i)).toFixed(2) + " " + sizes[i]; +} + export default function UsersSection() { const { data: session } = useSession(); const invalidateUserList = api.useUtils().users.list.invalidate; const { data: users } = api.users.list.useQuery(); + const { data: userStats } = api.admin.userStats.useQuery(); const { mutate: deleteUser, isPending: isDeletionPending } = api.users.delete.useMutation({ onSuccess: () => { @@ -35,7 +43,7 @@ export default function UsersSection() { }, }); - if (!users) { + if (!users || !userStats) { return <LoadingSpinner />; } @@ -47,6 +55,8 @@ export default function UsersSection() { <TableHeader className="bg-gray-200"> <TableHead>Name</TableHead> <TableHead>Email</TableHead> + <TableHead>Num Bookmarks</TableHead> + <TableHead>Asset Sizes</TableHead> <TableHead>Role</TableHead> <TableHead>Action</TableHead> </TableHeader> @@ -55,6 +65,12 @@ export default function UsersSection() { <TableRow key={u.id}> <TableCell className="py-1">{u.name}</TableCell> <TableCell className="py-1">{u.email}</TableCell> + <TableCell className="py-1"> + {userStats[u.id].numBookmarks} + </TableCell> + <TableCell className="py-1"> + {toHumanReadableSize(userStats[u.id].assetSizes)} + </TableCell> <TableCell className="py-1 capitalize">{u.role}</TableCell> <TableCell className="py-1"> <ActionButton diff --git a/apps/workers/index.ts b/apps/workers/index.ts index e576776a..f9a05e59 100644 --- a/apps/workers/index.ts +++ b/apps/workers/index.ts @@ -1,5 +1,7 @@ import "dotenv/config"; +import { TidyAssetsWorker } from "tidyAssetsWorker"; + import serverConfig from "@hoarder/shared/config"; import logger from "@hoarder/shared/logger"; import { runQueueDBMigrations } from "@hoarder/shared/queues"; @@ -13,21 +15,25 @@ async function main() { logger.info(`Workers version: ${serverConfig.serverVersion ?? "not set"}`); runQueueDBMigrations(); - const [crawler, openai, search] = [ + const [crawler, openai, search, tidyAssets] = [ await CrawlerWorker.build(), OpenAiWorker.build(), SearchIndexingWorker.build(), + TidyAssetsWorker.build(), ]; await Promise.any([ - Promise.all([crawler.run(), openai.run(), search.run()]), + Promise.all([crawler.run(), openai.run(), search.run(), tidyAssets.run()]), shutdownPromise, ]); - logger.info("Shutting down crawler, openai and search workers ..."); + logger.info( + "Shutting down crawler, openai, tidyAssets and search workers ...", + ); crawler.stop(); openai.stop(); search.stop(); + tidyAssets.stop(); } main(); diff --git a/apps/workers/tidyAssetsWorker.ts b/apps/workers/tidyAssetsWorker.ts new file mode 100644 index 00000000..bc14aab9 --- /dev/null +++ b/apps/workers/tidyAssetsWorker.ts @@ -0,0 +1,107 @@ +import { eq } from "drizzle-orm"; + +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; +import { DequeuedJob, Runner } from "@hoarder/queue"; +import { deleteAsset, getAllAssets } from "@hoarder/shared/assetdb"; +import logger from "@hoarder/shared/logger"; +import { + TidyAssetsQueue, + ZTidyAssetsRequest, + zTidyAssetsRequestSchema, +} from "@hoarder/shared/queues"; + +export class TidyAssetsWorker { + static build() { + logger.info("Starting tidy assets worker ..."); + const worker = new Runner<ZTidyAssetsRequest>( + TidyAssetsQueue, + { + run: runTidyAssets, + onComplete: (job) => { + const jobId = job?.id ?? "unknown"; + logger.info(`[tidyAssets][${jobId}] Completed successfully`); + return Promise.resolve(); + }, + onError: (job) => { + const jobId = job?.id ?? "unknown"; + logger.error( + `[tidyAssets][${jobId}] tidy assets job failed: ${job.error}\n${job.error.stack}`, + ); + return Promise.resolve(); + }, + }, + { + concurrency: 1, + pollIntervalMs: 1000, + timeoutSecs: 30, + }, + ); + + return worker; + } +} + +async function handleAsset( + asset: { + assetId: string; + userId: string; + size: number; + contentType: string; + fileName?: string | null; + }, + request: ZTidyAssetsRequest, + jobId: string, +) { + const dbRow = await db.query.assets.findFirst({ + where: eq(assets.id, asset.assetId), + }); + if (!dbRow) { + if (request.cleanDanglingAssets) { + await deleteAsset({ userId: asset.userId, assetId: asset.assetId }); + logger.info( + `[tidyAssets][${jobId}] Asset ${asset.assetId} not found in the database. Deleting it.`, + ); + } else { + logger.warn( + `[tidyAssets][${jobId}] Asset ${asset.assetId} not found in the database. Not deleting it because cleanDanglingAssets is false.`, + ); + } + return; + } + + if (request.syncAssetMetadata) { + await db + .update(assets) + .set({ + contentType: asset.contentType, + fileName: asset.fileName, + size: asset.size, + }) + .where(eq(assets.id, asset.assetId)); + logger.info( + `[tidyAssets][${jobId}] Updated metadata for asset ${asset.assetId}`, + ); + } +} + +async function runTidyAssets(job: DequeuedJob<ZTidyAssetsRequest>) { + const jobId = job.id ?? "unknown"; + + const request = zTidyAssetsRequestSchema.safeParse(job.data); + if (!request.success) { + throw new Error( + `[tidyAssets][${jobId}] Got malformed job request: ${request.error.toString()}`, + ); + } + + for await (const asset of getAllAssets()) { + try { + handleAsset(asset, request.data, jobId); + } catch (e) { + logger.error( + `[tidyAssets][${jobId}] Failed to tidy asset ${asset.assetId}: ${e}`, + ); + } + } +} diff --git a/packages/shared/assetdb.ts b/packages/shared/assetdb.ts index 4edfa1ec..64413e9f 100644 --- a/packages/shared/assetdb.ts +++ b/packages/shared/assetdb.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import * as path from "path"; +import { Glob } from "glob"; import { z } from "zod"; import serverConfig from "./config"; @@ -120,6 +121,25 @@ export async function readAsset({ return { asset, metadata }; } +export async function readAssetMetadata({ + userId, + assetId, +}: { + userId: string; + assetId: string; +}) { + const assetDir = getAssetDir(userId, assetId); + + const metadataStr = await fs.promises.readFile( + path.join(assetDir, "metadata.json"), + { + encoding: "utf8", + }, + ); + + return zAssetMetadataSchema.parse(JSON.parse(metadataStr)); +} + export async function getAssetSize({ userId, assetId, @@ -154,3 +174,25 @@ export async function deleteUserAssets({ userId }: { userId: string }) { } await fs.promises.rm(userDir, { recursive: true }); } + +export async function* getAllAssets() { + const g = new Glob(`/**/**/asset.bin`, { + maxDepth: 3, + root: ROOT_PATH, + cwd: ROOT_PATH, + absolute: false, + }); + for await (const file of g) { + const [userId, assetId] = file.split("/").slice(0, 2); + const [size, metadata] = await Promise.all([ + getAssetSize({ userId, assetId }), + readAssetMetadata({ userId, assetId }), + ]); + yield { + userId, + assetId, + ...metadata, + size, + }; + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 2b1ae973..69d93075 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,6 +6,7 @@ "type": "module", "dependencies": { "@hoarder/queue": "workspace:^0.1.0", + "glob": "^11.0.0", "meilisearch": "^0.37.0", "winston": "^3.11.0", "zod": "^3.22.4" diff --git a/packages/shared/queues.ts b/packages/shared/queues.ts index cadeefd0..6b04b988 100644 --- a/packages/shared/queues.ts +++ b/packages/shared/queues.ts @@ -65,6 +65,22 @@ export const SearchIndexingQueue = new SqliteQueue<ZSearchIndexingRequest>( }, ); +// Tidy Assets Worker +export const zTidyAssetsRequestSchema = z.object({ + cleanDanglingAssets: z.boolean().optional().default(false), + syncAssetMetadata: z.boolean().optional().default(false), +}); +export type ZTidyAssetsRequest = z.infer<typeof zTidyAssetsRequestSchema>; +export const TidyAssetsQueue = new SqliteQueue<ZTidyAssetsRequest>( + "tidy_assets_queue", + queueDB, + { + defaultJobArgs: { + numRetries: 1, + }, + }, +); + export async function triggerSearchReindex(bookmarkId: string) { await SearchIndexingQueue.enqueue({ bookmarkId, diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts index b3fb2383..ff1249d0 100644 --- a/packages/trpc/routers/admin.ts +++ b/packages/trpc/routers/admin.ts @@ -1,11 +1,12 @@ -import { count, eq } from "drizzle-orm"; +import { count, eq, sum } from "drizzle-orm"; import { z } from "zod"; -import { bookmarkLinks, bookmarks, users } from "@hoarder/db/schema"; +import { assets, bookmarkLinks, bookmarks, users } from "@hoarder/db/schema"; import { LinkCrawlerQueue, OpenAIQueue, SearchIndexingQueue, + TidyAssetsQueue, triggerSearchReindex, } from "@hoarder/shared/queues"; @@ -30,6 +31,9 @@ export const adminAppRouter = router({ indexingStats: z.object({ queued: z.number(), }), + tidyAssetsStats: z.object({ + queued: z.number(), + }), }), ) .query(async ({ ctx }) => { @@ -49,6 +53,9 @@ export const adminAppRouter = router({ queuedInferences, [{ value: pendingInference }], [{ value: failedInference }], + + // Tidy Assets + queuedTidyAssets, ] = await Promise.all([ ctx.db.select({ value: count() }).from(users), ctx.db.select({ value: count() }).from(bookmarks), @@ -77,6 +84,9 @@ export const adminAppRouter = router({ .select({ value: count() }) .from(bookmarks) .where(eq(bookmarks.taggingStatus, "failure")), + + // Tidy Assets + TidyAssetsQueue.stats(), ]); return { @@ -95,6 +105,9 @@ export const adminAppRouter = router({ indexingStats: { queued: queuedIndexing.pending + queuedIndexing.pending_retry, }, + tidyAssetsStats: { + queued: queuedTidyAssets.pending + queuedTidyAssets.pending_retry, + }, }; }), recrawlLinks: adminProcedure @@ -143,4 +156,52 @@ export const adminAppRouter = router({ bookmarkIds.map((b) => OpenAIQueue.enqueue({ bookmarkId: b.id })), ); }), + tidyAssets: adminProcedure.mutation(async () => { + await TidyAssetsQueue.enqueue({ + cleanDanglingAssets: true, + syncAssetMetadata: true, + }); + }), + userStats: adminProcedure + .output( + z.record( + z.string(), + z.object({ + numBookmarks: z.number(), + assetSizes: z.number(), + }), + ), + ) + .query(async ({ ctx }) => { + const [userIds, bookmarkStats, assetStats] = await Promise.all([ + ctx.db.select({ id: users.id }).from(users), + ctx.db + .select({ id: bookmarks.userId, value: count() }) + .from(bookmarks) + .groupBy(bookmarks.userId), + ctx.db + .select({ id: assets.userId, value: sum(assets.size) }) + .from(assets) + .groupBy(assets.userId), + ]); + + const results: Record< + string, + { numBookmarks: number; assetSizes: number } + > = {}; + for (const user of userIds) { + results[user.id] = { + numBookmarks: 0, + assetSizes: 0, + }; + } + for (const stat of bookmarkStats) { + results[stat.id].numBookmarks = stat.value; + } + for (const stat of assetStats) { + results[stat.id].assetSizes = parseInt(stat.value ?? "0"); + } + + return results; + }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a6cc45d..cb4e0106 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -906,6 +906,9 @@ importers: '@hoarder/queue': specifier: workspace:^0.1.0 version: link:../queue + glob: + specifier: ^11.0.0 + version: 11.0.0 meilisearch: specifier: ^0.37.0 version: 0.37.0 @@ -7260,6 +7263,11 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + glob@6.0.4: resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} deprecated: Glob versions prior to v9 are no longer supported @@ -8066,6 +8074,10 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + jake@10.8.7: resolution: {integrity: sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==} engines: {node: '>=10'} @@ -8521,6 +8533,10 @@ packages: resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} engines: {node: 14 || >=16.14} + lru-cache@11.0.1: + resolution: {integrity: sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -9017,6 +9033,10 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -9075,6 +9095,10 @@ packages: resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} @@ -9545,6 +9569,9 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-json@8.1.1: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} @@ -9642,6 +9669,10 @@ packages: resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} engines: {node: '>=16 || 14 >=14.17'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} @@ -22462,10 +22493,20 @@ snapshots: dependencies: foreground-child: 3.1.1 jackspeak: 2.3.6 - minimatch: 9.0.3 + minimatch: 9.0.4 minipass: 7.0.4 path-scurry: 1.10.1 + glob@11.0.0: + dependencies: + foreground-child: 3.1.1 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + dev: false + glob@6.0.4: dependencies: inflight: 1.0.6 @@ -23466,6 +23507,11 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + dev: false + jake@10.8.7: dependencies: async: 3.2.5 @@ -24044,6 +24090,9 @@ snapshots: lru-cache@10.2.0: {} + lru-cache@11.0.1: + dev: false + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -25090,6 +25139,11 @@ snapshots: minimalistic-assert@1.0.1: dev: false + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + dev: false + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -25106,11 +25160,11 @@ snapshots: minimatch@9.0.3: dependencies: brace-expansion: 2.0.1 + dev: true minimatch@9.0.4: dependencies: brace-expansion: 2.0.1 - dev: false minimist@1.2.8: {} @@ -25158,6 +25212,9 @@ snapshots: minipass@7.0.4: {} + minipass@7.1.2: + dev: false + minizlib@2.1.2: dependencies: minipass: 3.3.6 @@ -25770,6 +25827,9 @@ snapshots: netmask: 2.0.2 dev: false + package-json-from-dist@1.0.1: + dev: false + package-json@8.1.1: dependencies: got: 12.6.1 @@ -25893,6 +25953,12 @@ snapshots: lru-cache: 10.2.0 minipass: 7.0.4 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.1 + minipass: 7.1.2 + dev: false + path-to-regexp@0.1.7: dev: false |
