fix(qb-core): post-update recovery + centralizare notify 17mov_Hud

Restaurat jobs.lua din git (Quasar fork a suprascris joburile 17mov). Adăugat item map în items.lua (lipsea, rupt rv-maphold). Setat licences.driver = false în config.lua. Override QBCore.Functions.Notify + QBCore:Notify event → 17mov_Hud:ShowNotification (toate notificările merg automat prin 17mov_Hud).
This commit is contained in:
2026-04-03 02:47:59 +03:00
parent 06414ed181
commit e756e29294
1539 changed files with 51926 additions and 39806 deletions
@@ -0,0 +1 @@
ko_fi: thelindat
@@ -0,0 +1,41 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
## Issue checklist
> Please put `x` inside of the box that matches your issue.
- [ ] I am using the latest release
- [ ] I have referenced previously reported issues
- [ ] I have referenced available documentation and cannot resolve the issue
- [ ] I'm certain this is an issue with oxymysql, and not with my resource
**Describe the bug**
A clear and concise description of what the bug is.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Code**
```lua
-- If applicable, include the query and relevant code
```
**Expected behavior**
A clear and concise description of what you expected to happen.
**Additional context**
Add any other context about the problem here.
**Server details**
- FXServer artifact
- Operating system
**Database details**
- MariaDB or MySQL
- Version
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '32 5 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
@@ -0,0 +1,54 @@
name: Pre-Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Version tag'
required: true
jobs:
create-release:
name: Build and Create Tagged Pre release
runs-on: ubuntu-latest
steps:
- name: Install archive tools
run: sudo apt install zip
- name: Checkout source code
uses: actions/checkout@v2
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Install dependencies
run: pnpm i
- name: Run build
run: pnpm build
- name: Bundle files
run: |
mkdir -p ./temp/oxmysql
mkdir -p ./temp/oxmysql/web/
mkdir -p ./temp/oxmysql/lib/
cp ./{LICENSE,README.md,fxmanifest.lua,ui.lua} ./temp/oxmysql
cp ./lib/MySQL.lua ./temp/oxmysql/lib
cp -r ./dist ./temp/oxmysql
cp -r ./web/build ./temp/oxmysql/web/
cd ./temp && zip -r ../oxmysql.zip ./oxmysql
- name: Create Release
uses: 'marvinpinto/action-automatic-releases@v1.2.1'
id: auto_release
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: 'Experimental - ${{ github.event.inputs.tag }} (FOR TESTING PURPOSE)'
prerelease: true
automatic_release_tag: '${{ github.event.inputs.tag }}'
files: oxmysql-${{ github.event.inputs.tag }}.zip
env:
CI: false
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,78 @@
name: Build
on:
push:
tags:
- 'v*.*.*'
jobs:
create-release:
name: Build and Create Tagged release
runs-on: ubuntu-latest
steps:
- name: Install archive tools
run: sudo apt install zip
- name: Checkout source code
uses: actions/checkout@v3
with:
token: ${{ secrets.PAT_TOKEN }}
fetch-depth: 0
ref: ${{ github.event.repository.default_branch }}
- name: Install pnpm
uses: pnpm/action-setup@v4.0.0
with:
version: 9
- name: Get variables
id: get_vars
run: |
echo '::set-output name=SHORT_SHA::$(git rev-parse --short HEAD)'
echo '::set-output name=DATE::$(date +'%D')'
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 18.x
cache: 'pnpm'
cache-dependency-path: 'pnpm-lock.yaml'
- name: Install dependencies
run: pnpm i --frozen-lockfile
- name: Run build
run: pnpm build
env:
TGT_RELEASE_VERSION: ${{ github.ref_name }}
- name: Update repository
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
git commit -am '${{ github.ref_name }}'
git push
- name: Bundle files
run: |
mkdir -p ./temp/oxmysql/{web,lib}
cp ./{LICENSE,README.md,fxmanifest.lua,ui.lua} ./temp/oxmysql
cp ./lib/MySQL.lua ./temp/oxmysql/lib
cp -r ./dist ./logger ./temp/oxmysql
cp -r ./web/build ./temp/oxmysql/web/
cd ./temp && zip -r ../oxmysql.zip ./oxmysql
- name: Create Release
uses: 'marvinpinto/action-automatic-releases@v1.2.1'
id: auto_release
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: '${{ env.RELEASE_VERSION }}'
prerelease: false
files: oxmysql.zip
env:
CI: false
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,129 @@
.yarn.installed
fxmanifest.lua
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# JetBrains
.idea
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Wrapper
lib/MySQL.js
lib/MySQL.d.ts
lib/MySQL.js.map
@@ -0,0 +1,9 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true,
"trailingComma": "es5"
}
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
@@ -0,0 +1,34 @@
# oxmysql
A FiveM resource to communicate with a MySQL database using [node-mysql2](https://github.com/sidorares/node-mysql2).
![](https://img.shields.io/github/downloads/overextended/oxmysql/total?logo=github)
![](https://img.shields.io/github/downloads/overextended/oxmysql/latest/total?logo=github)
![](https://img.shields.io/github/contributors/overextended/oxmysql?logo=github)
![](https://img.shields.io/github/v/release/overextended/oxmysql?logo=github)
## 🔗 Links
- 💾 [Download](https://github.com/overextended/oxmysql/releases/latest/download/oxmysql.zip)
- Download the latest release directly.
- 📚 [Documentation](https://overextended.dev/oxmysql)
- For installation, setup, and everything else.
- 📦 [npm](https://www.npmjs.com/package/@overextended/oxmysql)
- Use our npm package for enhanced functionality and TypeScript support.
## ✨ Features
- Support for mysql-async and ghmattimysql syntax.
- Promises / async query handling allowing for non-blocking and awaitable responses.
- Improved performance and stability compared to other options.
- Support for named and unnamed placeholders, improving performance and security.
- Support for URI connection strings and semicolon separated values.
- Improved parameter checking and error handling.
## 🧾 Logging
We have included a module for submitting error logs to [Fivemanage](https://fivemanage.com/?ref=overextended), a cloud management service tailored for game servers. Additional logging options and support for other services will be available in the future.
## Lua Language Server
- Install [Lua Language Server](https://marketplace.visualstudio.com/items?itemName=sumneko.lua) to ease development with annotations, type checking, diagnostics, and more.
- See [ox_types](https://github.com/overextended/ox_types) for our Lua type definitions.
@@ -0,0 +1,74 @@
import { build } from 'esbuild';
import { readFileSync, writeFileSync } from 'fs';
const packageJson = JSON.parse(readFileSync('package.json', { encoding: 'utf8' }));
const version = process.env.TGT_RELEASE_VERSION;
if (version) {
packageJson.version = version.replace('v', '');
writeFileSync('package.json', JSON.stringify(packageJson, null, 2));
}
writeFileSync(
'.yarn.installed',
new Date().toLocaleString('en-AU', {
timeZone: 'UTC',
timeStyle: 'long',
dateStyle: 'full',
})
);
writeFileSync(
'fxmanifest.lua',
`fx_version 'cerulean'
game 'common'
use_experimental_fxv2_oal 'yes'
lua54 'yes'
node_version '22'
name '${packageJson.name}'
author '${packageJson.author}'
version '${packageJson.version}'
license '${packageJson.license}'
repository '${packageJson.repository.url}'
description '${packageJson.description}'
dependencies {
'/server:12913',
}
client_script 'ui.lua'
server_script 'dist/build.js'
files {
'web/build/index.html',
'web/build/**/*'
}
ui_page 'web/build/index.html'
provide 'mysql-async'
provide 'ghmattimysql'
convar_category 'OxMySQL' {
'Configuration',
{
{ 'Connection string', 'mysql_connection_string', 'CV_STRING', 'mysql://user:password@localhost/database' },
{ 'Debug', 'mysql_debug', 'CV_BOOL', 'false' }
}
}
`
);
build({
bundle: true,
entryPoints: [`./src/index.ts`],
outfile: `dist/build.js`,
keepNames: true,
dropLabels: ['DEV'],
legalComments: 'inline',
platform: 'node',
target: ['node22'],
format: 'cjs',
logLevel: 'info',
});
@@ -0,0 +1,5 @@
{
"packages": ["web"],
"npmClient": "pnpm",
"version": "0.0.0"
}
@@ -0,0 +1,2 @@
define.lua
MySQL.lua
@@ -0,0 +1,155 @@
local promise = promise
local Await = Citizen.Await
local resourceName = GetCurrentResourceName()
local GetResourceState = GetResourceState
local options = {
return_callback_errors = false
}
for i = 1, GetNumResourceMetadata(resourceName, 'mysql_option') do
local option = GetResourceMetadata(resourceName, 'mysql_option', i - 1)
options[option] = true
end
local function await(fn, query, parameters)
local p = promise.new()
fn(nil, query, parameters, function(result, error)
if error then
return p:reject(error)
end
p:resolve(result)
end, resourceName, true)
return Await(p)
end
local type = type
local queryStore = {}
local function safeArgs(query, parameters, cb, transaction)
local queryType = type(query)
if queryType == 'number' then
query = queryStore[query]
assert(query, "First argument received invalid query store reference")
elseif transaction then
if queryType ~= 'table' then
error(("First argument expected table, received '%s'"):format(query))
end
elseif queryType ~= 'string' then
error(("First argument expected string, received '%s'"):format(query))
end
if parameters then
local paramType = type(parameters)
if paramType ~= 'table' and paramType ~= 'function' then
error(("Second argument expected table or function, received '%s'"):format(parameters))
end
if paramType == 'function' or parameters.__cfx_functionReference then
cb = parameters
parameters = nil
end
end
if cb and parameters then
local cbType = type(cb)
if cbType ~= 'function' and (cbType == 'table' and not cb.__cfx_functionReference) then
error(("Third argument expected function, received '%s'"):format(cb))
end
end
return query, parameters, cb
end
local oxmysql = exports.oxmysql
local mysql_method_mt = {
__call = function(self, query, parameters, cb)
query, parameters, cb = safeArgs(query, parameters, cb, self.method == 'transaction')
return oxmysql[self.method](nil, query, parameters, cb, resourceName, options.return_callback_errors)
end
}
local MySQL = setmetatable(MySQL or {}, {
__index = function(_, index)
return function(...)
return oxmysql[index](nil, ...)
end
end
})
for _, method in pairs({
'scalar', 'single', 'query', 'insert', 'update', 'prepare', 'transaction', 'rawExecute',
}) do
MySQL[method] = setmetatable({
method = method,
await = function(query, parameters)
query, parameters = safeArgs(query, parameters, nil, method == 'transaction')
return await(oxmysql[method], query, parameters)
end
}, mysql_method_mt)
end
local alias = {
fetchAll = 'query',
fetchScalar = 'scalar',
fetchSingle = 'single',
insert = 'insert',
execute = 'update',
transaction = 'transaction',
prepare = 'prepare'
}
local alias_mt = {
__index = function(self, key)
if alias[key] then
local method = MySQL[alias[key]]
MySQL.Async[key] = method
MySQL.Sync[key] = method.await
alias[key] = nil
return self[key]
end
end
}
local function addStore(query, cb)
assert(type(query) == 'string', 'The SQL Query must be a string')
local storeN = #queryStore + 1
queryStore[storeN] = query
return cb and cb(storeN) or storeN
end
MySQL.Sync = setmetatable({ store = addStore }, alias_mt)
MySQL.Async = setmetatable({ store = addStore }, alias_mt)
local function onReady(cb)
while GetResourceState('oxmysql') ~= 'started' do
Wait(50)
end
oxmysql.awaitConnection()
return cb and cb() or true
end
MySQL.ready = setmetatable({
await = onReady
}, {
__call = function(_, cb)
Citizen.CreateThreadNow(function() onReady(cb) end)
end,
})
function MySQL.startTransaction(cb)
return oxmysql:startTransaction(cb, resourceName)
end
_ENV.MySQL = MySQL
@@ -0,0 +1,168 @@
type Query = string | number;
type Params = Record<string, unknown> | unknown[] | Function;
type Callback<T> = (result: T | null) => void;
type Transaction =
| string[]
| [string, Params][]
| { query: string; values: Params }[]
| { query: string; parameters: Params }[];
interface Result {
[column: string | number]: any;
affectedRows?: number;
fieldCount?: number;
info?: string;
insertId?: number;
serverStatus?: number;
warningStatus?: number;
changedRows?: number;
}
interface Row {
[column: string | number]: unknown;
}
interface OxMySQL {
store: (query: string) => void;
ready: (callback: () => void) => void;
query: <T = Result | null>(query: Query, params?: Params | Callback<T>, cb?: Callback<T>) => Promise<T>;
single: <T = Row | null>(
query: Query,
params?: Params | Callback<Exclude<T, []>>,
cb?: Callback<Exclude<T, []>>
) => Promise<Exclude<T, []>>;
scalar: <T = unknown | null>(
query: Query,
params?: Params | Callback<Exclude<T, []>>,
cb?: Callback<Exclude<T, []>>
) => Promise<Exclude<T, []>>;
update: <T = number | null>(query: Query, params?: Params | Callback<T>, cb?: Callback<T>) => Promise<T>;
insert: <T = number | null>(query: Query, params?: Params | Callback<T>, cb?: Callback<T>) => Promise<T>;
prepare: <T = any>(query: Query, params?: Params | Callback<T>, cb?: Callback<T>) => Promise<T>;
rawExecute: <T = Result | null>(query: Query, params?: Params | Callback<T>, cb?: Callback<T>) => Promise<T>;
transaction: (query: Transaction, params?: Params | Callback<boolean>, cb?: Callback<boolean>) => Promise<boolean>;
isReady: () => boolean;
awaitConnection: () => Promise<true>;
startTransaction: (
cb: (query: <T = Result | null>(statement: string, params?: Params) => Promise<T>) => Promise<boolean | void>
) => Promise<boolean>;
}
const QueryStore: string[] = [];
function assert(condition: boolean, message: string) {
if (!condition) throw new TypeError(message);
}
const safeArgs = (query: Query | Transaction, params?: any, cb?: Function, transaction?: true) => {
if (typeof query === 'number') {
query = QueryStore[query];
assert(typeof query === 'string', 'First argument received invalid query store reference');
}
if (transaction) {
assert(typeof query === 'object', `First argument expected object, recieved ${typeof query}`);
} else {
assert(typeof query === 'string', `First argument expected string, received ${typeof query}`);
}
if (params) {
const paramType = typeof params;
assert(
paramType === 'object' || paramType === 'function',
`Second argument expected object or function, received ${paramType}`
);
if (!cb && paramType === 'function') {
cb = params;
params = undefined;
}
}
if (cb !== undefined) assert(typeof cb === 'function', `Third argument expected function, received ${typeof cb}`);
return [query, params, cb];
};
declare var global: any;
const exp = global.exports.oxmysql;
const currentResourceName = GetCurrentResourceName();
function execute(method: string, query: Query | Transaction, params?: Params) {
return new Promise((resolve, reject) => {
exp[method](
query,
params,
(result, error) => {
if (error) return reject(error);
resolve(result);
},
currentResourceName,
true
);
}) as any;
}
export const oxmysql: OxMySQL = {
store(query) {
assert(typeof query !== 'string', `Query expects a string, received ${typeof query}`);
return QueryStore.push(query);
},
ready(callback) {
setImmediate(async () => {
while (GetResourceState('oxmysql') !== 'started') await new Promise((resolve) => setTimeout(resolve, 50, null));
callback();
});
},
async query(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('query', query, params);
return cb ? cb(result) : result;
},
async single(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('single', query, params);
return cb ? cb(result) : result;
},
async scalar(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('scalar', query, params);
return cb ? cb(result) : result;
},
async update(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('update', query, params);
return cb ? cb(result) : result;
},
async insert(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('insert', query, params);
return cb ? cb(result) : result;
},
async prepare(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('prepare', query, params);
return cb ? cb(result) : result;
},
async rawExecute(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb);
const result = await execute('rawExecute', query, params);
return cb ? cb(result) : result;
},
async transaction(query, params, cb) {
[query, params, cb] = safeArgs(query, params, cb, true);
const result = await execute('transaction', query, params);
return cb ? cb(result) : result;
},
isReady() {
return exp.isReady();
},
async awaitConnection() {
return await exp.awaitConnection();
},
async startTransaction(cb) {
return exp.startTransaction(cb, currentResourceName);
},
};
@@ -0,0 +1,51 @@
# OxMySQL exports wrapper for FiveM
Types are fully supported and you will get intellisense on the `oxmysql` object when using it.
## Installation
```yaml
# With pnpm
pnpm add @overextended/oxmysql
# With Yarn
yarn add @overextended/oxmysql
# With npm
npm install @overextended/oxmysql
```
## Usage
Import as module:
```js
import { oxmysql } from '@overextended/oxmysql';
```
Import with require:
```js
const { oxmysql } = require('@overextended/oxmysql');
```
## Documentation
[View documentation](https://overextended.github.io/docs/oxmysql)
```js
oxmysql.scalar('SELECT username FROM users', (result) => {
console.log(result)
}).catch(console.error)
oxmysql.scalar('SELECT username FROM users').then((result) => {
console.log(result)
}).catch(console.error)
const result = await oxmysql.scalar('SELECT username FROM users').catch(console.error)
console.log(result)
```
## License
LGPL-3.0
@@ -0,0 +1,25 @@
{
"name": "@overextended/oxmysql",
"version": "1.4.2",
"description": "Exports wrapper for oxmysql",
"types": "MySQL.d.ts",
"main": "MySQL.js",
"scripts": {
"prepublish": "tsc"
},
"repository": {
"type": "git",
"url": "git+https://github.com/overextended/oxmysql.git"
},
"keywords": [
"fivem",
"oxmysql",
"sql"
],
"author": "Overextended",
"license": "LGPL-3.0",
"bugs": {
"url": "https://github.com/overextended/oxmysql/issues"
},
"homepage": "https://github.com/overextended/oxmysql#readme"
}
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": ".",
"strict": true,
"module": "CommonJS",
"types": ["@citizenfx/server"],
"declaration": true,
"sourceMap": true,
"target": "ESNext",
"allowJs": true,
"lib": ["ESNext"],
"noEmitOnError": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"rootDir": ".",
"moduleResolution": "node",
"experimentalDecorators": true,
"noImplicitAny": false,
"noEmit": false
},
"include": ["MySQL.ts"],
"exclude": ["**/node_modules"]
}
@@ -0,0 +1,38 @@
// https://fivemanage.com/?ref=overextended
const apiKey = GetConvar('FIVEMANAGE_LOGS_API_KEY', '');
if (!apiKey) return console.warning(`convar "FIVEMANAGE_LOGS_API_KEY" has not been set`);
const batchedLogs = [];
const endpoint = 'https://api.fivemanage.com/api/logs/batch';
const headers = {
['Content-Type']: 'application/json',
['Authorization']: apiKey,
['User-Agent']: 'oxmysql',
};
async function sendLogs() {
try {
const body = JSON.stringify(batchedLogs);
batchedLogs.length = 0;
const response = await fetch(endpoint, {
method: 'POST',
body: body,
headers: headers,
});
if (response.ok) return;
console.error(`Failed to submit logs to fivemanage - ${response.status} ${response.statusText}`);
} catch (err) {
console.error(err);
}
}
return function logger(data) {
if (batchedLogs.length === 0) setTimeout(sendLogs, 500);
batchedLogs.push(data);
};
@@ -0,0 +1,43 @@
{
"name": "oxmysql",
"version": "2.13.0",
"description": "FXServer to MySQL communication via node-mysql2",
"repository": {
"type": "git",
"url": "https://github.com/overextended/oxmysql.git"
},
"bugs": "https://github.com/overextended/oxmysql/issues",
"author": "Overextended",
"license": "LGPL-3.0-or-later",
"type": "module",
"main": "lib/MySQL.js",
"types": "lib/MySQL.d.ts",
"files": [
"lib/MySQL.js",
"lib/MySQL.d.ts"
],
"scripts": {
"build": "pnpm build:root && lerna run build",
"build:root": "node build.js && cd lib && tsc",
"watch": "esbuild --watch --bundle --platform=node --target=node16.9.1 src/index.ts --outfile=dist/build.js",
"lib": "tsc --project lib/tsconfig.lib.json",
"bootstrap": "lerna bootstrap",
"postinstall": "patch-package && pnpm bootstrap"
},
"dependencies": {
"mysql2": "3.11.3",
"named-placeholders": "^1.1.3",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@citizenfx/server": "2.0.5132-1",
"@milahu/patch-package": "^6.4.14",
"@types/node": "^22.7.4",
"esbuild": "^0.21.5",
"lerna": "^4.0.0",
"postinstall-postinstall": "^2.1.0",
"prettier": "^3.3.3",
"pretty-quick": "^3.3.1",
"typescript": "^5.6.2"
}
}
@@ -0,0 +1,174 @@
# generated by patch-package 6.4.14
#
# declared package:
# mysql2: 3.11.3
#
diff --git a/node_modules/mysql2/lib/connection.js b/node_modules/mysql2/lib/connection.js
index af6b3d9..a427374 100644
--- a/node_modules/mysql2/lib/connection.js
+++ b/node_modules/mysql2/lib/connection.js
@@ -32,7 +32,7 @@ const CharsetToEncoding = require('./constants/charset_encodings.js');
let _connectionId = 0;
-let convertNamedPlaceholders = null;
+// let convertNamedPlaceholders = null;
class Connection extends EventEmitter {
constructor(opts) {
@@ -524,7 +524,7 @@ class Connection extends EventEmitter {
sql: sql,
values: values
};
- this._resolveNamedPlaceholders(opts);
+ // this._resolveNamedPlaceholders(opts);
return SqlString.format(
opts.sql,
opts.values,
@@ -546,20 +546,20 @@ class Connection extends EventEmitter {
}
_resolveNamedPlaceholders(options) {
- let unnamed;
- if (this.config.namedPlaceholders || options.namedPlaceholders) {
- if (Array.isArray(options.values)) {
- // if an array is provided as the values, assume the conversion is not necessary.
- // this allows the usage of unnamed placeholders even if the namedPlaceholders flag is enabled.
- return
- }
- if (convertNamedPlaceholders === null) {
- convertNamedPlaceholders = require('named-placeholders')();
- }
- unnamed = convertNamedPlaceholders(options.sql, options.values);
- options.sql = unnamed[0];
- options.values = unnamed[1];
- }
+ // let unnamed;
+ // if (this.config.namedPlaceholders || options.namedPlaceholders) {
+ // if (Array.isArray(options.values)) {
+ // // if an array is provided as the values, assume the conversion is not necessary.
+ // // this allows the usage of unnamed placeholders even if the namedPlaceholders flag is enabled.
+ // return
+ // }
+ // if (convertNamedPlaceholders === null) {
+ // convertNamedPlaceholders = require('named-placeholders')();
+ // }
+ // unnamed = convertNamedPlaceholders(options.sql, options.values);
+ // options.sql = unnamed[0];
+ // options.values = unnamed[1];
+ // }
}
query(sql, values, cb) {
@@ -569,7 +569,7 @@ class Connection extends EventEmitter {
} else {
cmdQuery = Connection.createQuery(sql, values, cb, this.config);
}
- this._resolveNamedPlaceholders(cmdQuery);
+ // this._resolveNamedPlaceholders(cmdQuery);
const rawSql = this.format(cmdQuery.sql, cmdQuery.values !== undefined ? cmdQuery.values : []);
cmdQuery.sql = rawSql;
return this.addCommand(cmdQuery);
@@ -644,26 +644,27 @@ class Connection extends EventEmitter {
options.sql = sql;
options.values = values;
}
- this._resolveNamedPlaceholders(options);
+ // this._resolveNamedPlaceholders(options);
// check for values containing undefined
if (options.values) {
//If namedPlaceholder is not enabled and object is passed as bind parameters
- if (!Array.isArray(options.values)) {
- throw new TypeError(
- 'Bind parameters must be array if namedPlaceholders parameter is not enabled'
- );
- }
+ // if (!Array.isArray(options.values)) {
+ // throw new TypeError(
+ // 'Bind parameters must be array if namedPlaceholders parameter is not enabled'
+ // );
+ // }
options.values.forEach(val => {
//If namedPlaceholder is not enabled and object is passed as bind parameters
- if (!Array.isArray(options.values)) {
- throw new TypeError(
- 'Bind parameters must be array if namedPlaceholders parameter is not enabled'
- );
- }
+ // if (!Array.isArray(options.values)) {
+ // throw new TypeError(
+ // 'Bind parameters must be array if namedPlaceholders parameter is not enabled'
+ // );
+ // }
if (val === undefined) {
- throw new TypeError(
- 'Bind parameters must not contain undefined. To pass SQL NULL specify JS null'
- );
+ // throw new TypeError(
+ // 'Bind parameters must not contain undefined. To pass SQL NULL specify JS null'
+ // );
+ val = null
}
if (typeof val === 'function') {
throw new TypeError(
diff --git a/node_modules/mysql2/lib/packets/execute.js b/node_modules/mysql2/lib/packets/execute.js
index daf1df9..0862ce1 100644
--- a/node_modules/mysql2/lib/packets/execute.js
+++ b/node_modules/mysql2/lib/packets/execute.js
@@ -28,7 +28,10 @@ function toParameter(value, encoding, timezone) {
if (value !== null) {
switch (typeof value) {
case 'undefined':
- throw new TypeError('Bind parameters must not contain undefined');
+ // throw new TypeError('Bind parameters must not contain undefined');
+ value = '';
+ type = Types.NULL;
+ break;
case 'number':
type = Types.DOUBLE;
diff --git a/node_modules/mysql2/lib/parsers/binary_parser.js b/node_modules/mysql2/lib/parsers/binary_parser.js
index 4eb0d3a..bf6dbec 100644
--- a/node_modules/mysql2/lib/parsers/binary_parser.js
+++ b/node_modules/mysql2/lib/parsers/binary_parser.js
@@ -77,7 +77,7 @@ function readCodeFor(field, config, options, fieldNum) {
default:
if (field.characterSet === Charsets.BINARY) {
- return 'packet.readLengthCodedBuffer();';
+ return '[...packet.readLengthCodedBuffer()];';
}
return `packet.readLengthCodedString(fields[${fieldNum}].encoding)`;
}
@@ -94,6 +94,7 @@ function compile(fields, options, config) {
db: field.schema,
table: field.table,
name: field.name,
+ charset: field.characterSet,
string: function (encoding = field.encoding) {
if (field.columnType === Types.JSON && encoding === field.encoding) {
// Since for JSON columns mysql always returns charset 63 (BINARY),
diff --git a/node_modules/mysql2/lib/parsers/text_parser.js b/node_modules/mysql2/lib/parsers/text_parser.js
index deedadf..7a4a6c8 100644
--- a/node_modules/mysql2/lib/parsers/text_parser.js
+++ b/node_modules/mysql2/lib/parsers/text_parser.js
@@ -90,6 +90,7 @@ function compile(fields, options, config) {
db: field.schema,
table: field.table,
name: field.name,
+ charset: field.characterSet,
string: function (encoding = field.encoding) {
if (field.columnType === Types.JSON && encoding === field.encoding) {
// Since for JSON columns mysql always returns charset 63 (BINARY),
diff --git a/node_modules/mysql2/typings/mysql/lib/parsers/typeCast.d.ts b/node_modules/mysql2/typings/mysql/lib/parsers/typeCast.d.ts
index b8e9751..ca8fbda 100644
--- a/node_modules/mysql2/typings/mysql/lib/parsers/typeCast.d.ts
+++ b/node_modules/mysql2/typings/mysql/lib/parsers/typeCast.d.ts
@@ -44,6 +44,7 @@ export type Field = Type & {
db: string;
table: string;
name: string;
+ charset: number;
string: (encoding?: BufferEncoding | string | undefined) => string | null;
buffer: () => Buffer | null;
geometry: () => Geometry | Geometry[] | null;
@@ -0,0 +1,65 @@
# generated by patch-package 6.4.14
#
# declared package:
# named-placeholders: ^1.1.3
#
diff --git a/node_modules/named-placeholders/index.js b/node_modules/named-placeholders/index.js
index 3524ef5..e47335e 100644
--- a/node_modules/named-placeholders/index.js
+++ b/node_modules/named-placeholders/index.js
@@ -3,7 +3,7 @@
// based on code from Brian White @mscdex mariasql library - https://github.com/mscdex/node-mariasql/blob/master/lib/Client.js#L272-L332
// License: https://github.com/mscdex/node-mariasql/blob/master/LICENSE
-const RE_PARAM = /(?:\?)|(?::(\d+|(?:[a-zA-Z][a-zA-Z0-9_]*)))/g,
+const RE_PARAM = /(?:\?)|(?:(?<!["'])[:@](\d+|(?:[a-zA-Z][a-zA-Z0-9_]*)))/g,
DQUOTE = 34,
SQUOTE = 39,
BSLASH = 92;
@@ -92,15 +92,24 @@ function createCompiler(config) {
if (typeof params == 'undefined')
throw new Error('Named query contains placeholders, but parameters object is undefined');
+ for(const key in params) {
+ const char = key[0]
+ if(char == '@' || char == ':') {
+ params[key.substring(1)] = params[key];
+ delete params[key];
+ }
+ }
+
const tokens = tree[1];
for (let i=0; i < tokens.length; ++i) {
- arr.push(params[tokens[i]]);
+ arr.push(params[tokens[i]] === undefined ? null : params[tokens[i]]);
}
return [tree[0], arr];
}
function noTailingSemicolon(s) {
- if (s.slice(-1) == ':') {
+ const char = s.slice(-1)
+ if (char == ':' || char == '@') {
return s.slice(0, -1);
}
return s;
@@ -113,7 +122,8 @@ function createCompiler(config) {
let unnamed = noTailingSemicolon(tree[0][0]);
for (let i=1; i < tree[0].length; ++i) {
- if (tree[0][i-1].slice(-1) == ':') {
+ const char = tree[0][i-1].slice(-1)
+ if (char == ':' || char == '@') {
unnamed += config.placeholder;
}
unnamed += config.placeholder;
@@ -122,7 +132,8 @@ function createCompiler(config) {
const last = tree[0][tree[0].length -1];
if (tree[0].length == tree[1].length) {
- if (last.slice(-1) == ':') {
+ const char = last.slice(-1)
+ if (char == ':' || char == '@') {
unnamed += config.placeholder;
}
unnamed += config.placeholder;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
export default {
query: 'execute',
scalar: 'scalar',
transaction: 'transaction',
store: 'store',
};
@@ -0,0 +1,8 @@
export default {
update: 'mysql_execute',
insert: 'mysql_insert',
query: 'mysql_fetch_all',
scalar: 'mysql_fetch_scalar',
transaction: 'mysql_transaction',
store: 'mysql_store',
};
@@ -0,0 +1,143 @@
import type { ConnectionOptions } from 'mysql2';
import { typeCast } from './utils/typeCast';
export const mysql_connection_string = GetConvar('mysql_connection_string', '');
export let mysql_ui = GetConvar('mysql_ui', 'false') === 'true';
export let mysql_slow_query_warning = GetConvarInt('mysql_slow_query_warning', 200);
export let mysql_debug: boolean | string[] = false;
// max array size of individual resource query logs
// prevent excessive memory use when people use debug/ui in production
export let mysql_log_size = 0;
export function setDebug() {
mysql_ui = GetConvar('mysql_ui', 'false') === 'true';
mysql_slow_query_warning = GetConvarInt('mysql_slow_query_warning', 200);
try {
const debug = GetConvar('mysql_debug', 'false');
mysql_debug = debug === 'false' ? false : JSON.parse(debug);
} catch (e) {
mysql_debug = true;
}
mysql_log_size = mysql_debug ? 10000 : GetConvarInt('mysql_log_size', 100);
}
export const mysql_transaction_isolation_level = (() => {
const query = 'SET TRANSACTION ISOLATION LEVEL';
switch (GetConvarInt('mysql_transaction_isolation_level', 2)) {
case 1:
return `${query} REPEATABLE READ`;
case 2:
return `${query} READ COMMITTED`;
case 3:
return `${query} READ UNCOMMITTED`;
case 4:
return `${query} SERIALIZABLE`;
default:
return `${query} READ COMMITTED`;
}
})();
function parseUri(connectionString: string) {
const splitMatchGroups = connectionString.match(
new RegExp(
'^(?:([^:/?#.]+):)?(?://(?:([^/?#]*)@)?([\\w\\d\\-\\u0100-\\uffff.%]*)(?::([0-9]+))?)?([^?#]+)?(?:\\?([^#]*))?$'
)
) as RegExpMatchArray;
if (!splitMatchGroups) throw new Error(`mysql_connection_string structure was invalid (${connectionString})`);
const authTarget = splitMatchGroups[2] ? splitMatchGroups[2].split(':') : [];
const options = {
user: authTarget[0] || undefined,
password: authTarget[1] || undefined,
host: splitMatchGroups[3],
port: parseInt(splitMatchGroups[4]),
database: splitMatchGroups[5]?.replace(/^\/+/, ''),
...(splitMatchGroups[6] &&
splitMatchGroups[6].split('&').reduce<Record<string, string>>((connectionInfo, parameter) => {
const [key, value] = parameter.split('=');
connectionInfo[key] = value;
return connectionInfo;
}, {})),
};
return options;
}
export let convertNamedPlaceholders: null | ((query: string, parameters: Record<string, any>) => [string, any[]]);
export function getConnectionOptions(): ConnectionOptions {
const options: Record<string, any> = mysql_connection_string.includes('mysql://')
? parseUri(mysql_connection_string)
: mysql_connection_string
.replace(/(?:host(?:name)|ip|server|data\s?source|addr(?:ess)?)=/gi, 'host=')
.replace(/(?:user\s?(?:id|name)?|uid)=/gi, 'user=')
.replace(/(?:pwd|pass)=/gi, 'password=')
.replace(/(?:db)=/gi, 'database=')
.split(';')
.reduce<Record<string, string>>((connectionInfo, parameter) => {
const [key, value] = parameter.split('=');
if (key) connectionInfo[key] = value;
return connectionInfo;
}, {});
convertNamedPlaceholders = options.namedPlaceholders === 'false' ? null : require('named-placeholders')();
for (const key of ['dateStrings', 'flags', 'ssl']) {
const value = options[key];
if (typeof value === 'string') {
try {
options[key] = JSON.parse(value);
} catch (err) {
console.log(`^3Failed to parse property ${key} in configuration (${err})!^0`);
}
}
}
const flags: string[] = options.flags || [];
flags.push(options.database ? 'CONNECT_WITH_DB' : '-CONNECT_WITH_DB');
return {
connectTimeout: 60000,
trace: false,
supportBigNumbers: true,
jsonStrings: true,
...options,
typeCast,
namedPlaceholders: false, // we use our own named-placeholders patch, disable mysql2s
flags: flags,
};
}
RegisterCommand(
'oxmysql_debug',
(source: number, args: string[]) => {
if (source !== 0) return console.log('^3This command can only be run server side^0');
switch (args[0]) {
case 'add':
if (!Array.isArray(mysql_debug)) mysql_debug = [];
mysql_debug.push(args[1]);
SetConvar('mysql_debug', JSON.stringify(mysql_debug));
return console.log(`^3Added ${args[1]} to mysql_debug^0`);
case 'remove':
if (Array.isArray(mysql_debug)) {
const index = mysql_debug.indexOf(args[1]);
if (index === -1) return;
mysql_debug.splice(index, 1);
if (mysql_debug.length === 0) mysql_debug = false;
SetConvar('mysql_debug', JSON.stringify(mysql_debug) || 'false');
return console.log(`^3Removed ${args[1]} from mysql_debug^0`);
}
default:
return console.log(`^3Usage: oxmysql add|remove <resource>^0`);
}
},
true
);
@@ -0,0 +1,75 @@
import type { Connection, PoolConnection, TypeCast } from 'mysql2/promise';
import { scheduleTick } from '../utils/scheduleTick';
import { sleep } from '../utils/sleep';
import { pool } from './pool';
import type { CFXParameters } from 'types';
import { typeCastExecute } from 'utils/typeCast';
(Symbol as any).dispose ??= Symbol('Symbol.dispose');
const activeConnections: Record<string, MySql> = {};
interface PromisePoolConnection extends Connection {
connection: PoolConnection;
release: PoolConnection['release'];
}
export class MySql {
id: number;
connection: PromisePoolConnection;
transaction?: boolean;
constructor(connection: PromisePoolConnection) {
this.id = connection.connection.threadId;
this.connection = connection;
activeConnections[this.id] = this;
}
async query(query: string, values: CFXParameters = []) {
scheduleTick();
const [result] = await this.connection.query(query, values);
return result;
}
async execute(query: string, values: CFXParameters = []) {
scheduleTick();
const [result] = await this.connection.execute({
sql: query,
values: values,
typeCast: typeCastExecute,
});
return result;
}
beginTransaction() {
this.transaction = true;
return this.connection.beginTransaction();
}
rollback() {
delete this.transaction;
return this.connection.rollback();
}
commit() {
delete this.transaction;
return this.connection.commit();
}
[Symbol.dispose]() {
if (this.transaction) this.commit();
delete activeConnections[this.id];
this.connection.release();
}
}
export async function getConnection(connectionId?: number) {
while (!pool) await sleep(0);
return connectionId
? activeConnections[connectionId]
: new MySql((await pool.getConnection()) as unknown as PromisePoolConnection);
}
@@ -0,0 +1,23 @@
import { setDebug } from '../config';
import { sleep } from '../utils/sleep';
import { pool, createConnectionPool } from './pool';
setTimeout(async () => {
setDebug();
while (!pool) {
await createConnectionPool();
if (!pool) await sleep(30000);
}
});
setInterval(() => {
setDebug();
}, 1000);
export * from './connection';
export * from './rawQuery';
export * from './rawExecute';
export * from './rawTransaction';
export * from './pool';
@@ -0,0 +1,46 @@
import { getConnectionOptions, mysql_transaction_isolation_level } from 'config';
import { createPool } from 'mysql2/promise';
import type { Pool, RowDataPacket } from 'mysql2/promise';
import { getConnection } from './connection';
export let pool: Pool;
export let dbVersion = '';
export async function createConnectionPool() {
const config = getConnectionOptions();
try {
const dbPool = createPool(config);
dbPool.on('connection', (connection) => {
connection.query(mysql_transaction_isolation_level);
});
const [result] = (await dbPool.query('SELECT VERSION() as version')) as RowDataPacket[];
dbVersion = `^5[${result[0].version}]`;
console.log(`${dbVersion} ^2Database server connection established!^0`);
if (config.multipleStatements) {
console.warn(`multipleStatements is enabled. Used incorrectly, this option may cause SQL injection.`);
}
pool = dbPool;
} catch (err: any) {
const message = err.message.includes('auth_gssapi_client')
? `Requested authentication using unknown plugin auth_gssapi_client.`
: err.message;
console.log(
`^3Unable to establish a connection to the database (${err.code})!\n^1Error${
err.errno ? ` ${err.errno}` : ''
}: ${message}^0`
);
console.log(`See https://github.com/overextended/oxmysql/issues/154 for more information.`);
if (config.password) config.password = '******';
console.log(config);
}
}
@@ -0,0 +1,93 @@
import { logError, logQuery } from '../logger';
import { CFXCallback, CFXParameters, QueryType } from '../types';
import { parseResponse } from '../utils/parseResponse';
import { executeType, parseExecute } from '../utils/parseExecute';
import { getConnection } from './connection';
import { setCallback } from '../utils/setCallback';
import { performance } from 'perf_hooks';
import validateResultSet from 'utils/validateResultSet';
import { RowDataPacket } from 'mysql2';
import { profileBatchStatements, runProfiler } from 'profiler';
export const rawExecute = async (
invokingResource: string,
query: string,
parameters: CFXParameters,
cb?: CFXCallback,
isPromise?: boolean,
unpack?: boolean,
connectionId?: number
) => {
cb = setCallback(parameters, cb);
let type: QueryType;
let placeholders: number;
try {
type = executeType(query);
placeholders = query.split('?').length - 1;
parameters = parseExecute(placeholders, parameters);
} catch (err: any) {
return logError(invokingResource, cb, isPromise, err, query, parameters);
}
using connection = await getConnection(connectionId);
if (!connection) return;
try {
const hasProfiler = await runProfiler(connection, invokingResource);
const parametersLength = parameters.length == 0 ? 1 : parameters.length;
const response = [] as any[];
for (let index = 0; index < parametersLength; index++) {
const values = parameters[index];
if (values && placeholders > values.length) {
for (let i = values.length; i < placeholders; i++) {
values[i] = null;
}
}
const startTime = !hasProfiler && performance.now();
const result = await connection.execute(query, values);
if (Array.isArray(result) && result.length > 1) {
for (const value of result) {
response.push(unpack ? parseResponse(type, value as RowDataPacket[]) : value);
}
} else response.push(unpack ? parseResponse(type, result) : result);
if (hasProfiler && ((index > 0 && index % 100 === 0) || index === parametersLength - 1)) {
await profileBatchStatements(connection, invokingResource, query, parameters, index < 100 ? 0 : index);
} else if (startTime) {
logQuery(invokingResource, query, performance.now() - startTime, values);
}
validateResultSet(invokingResource, query, result);
}
if (!cb) return response.length === 1 ? response[0] : response;
try {
if (response.length === 1) {
if (unpack && type === null) {
if (response[0][0] && Object.keys(response[0][0]).length === 1) {
cb(Object.values(response[0][0])[0]);
} else cb(response[0][0]);
} else {
cb(response[0]);
}
} else {
cb(response);
}
} catch (err) {
if (typeof err === 'string') {
if (err.includes('SCRIPT ERROR:')) return console.log(err);
console.log(`^1SCRIPT ERROR in invoking resource ${invokingResource}: ${err}^0`);
}
}
} catch (err: any) {
logError(invokingResource, cb, isPromise, err, query, parameters);
}
};
@@ -0,0 +1,63 @@
import { parseArguments } from '../utils/parseArguments';
import { setCallback } from '../utils/setCallback';
import { parseResponse } from '../utils/parseResponse';
import { logQuery, logError } from '../logger';
import type { CFXCallback, CFXParameters } from '../types';
import type { QueryType } from '../types';
import { getConnection } from './connection';
import { RowDataPacket } from 'mysql2';
import { performance } from 'perf_hooks';
import validateResultSet from 'utils/validateResultSet';
import { runProfiler } from 'profiler';
export const rawQuery = async (
type: QueryType,
invokingResource: string,
query: string,
parameters: CFXParameters,
cb?: CFXCallback,
isPromise?: boolean,
connectionId?: number
) => {
cb = setCallback(parameters, cb);
try {
[query, parameters] = parseArguments(query, parameters);
} catch (err: any) {
return logError(invokingResource, cb, isPromise, err, query, parameters);
}
using connection = await getConnection(connectionId);
if (!connection) return;
try {
const hasProfiler = await runProfiler(connection, invokingResource);
const startTime = !hasProfiler && performance.now();
const result = await connection.query(query, parameters);
if (hasProfiler) {
const profiler = <RowDataPacket[]>(
await connection.query('SELECT FORMAT(SUM(DURATION) * 1000, 4) AS `duration` FROM INFORMATION_SCHEMA.PROFILING')
);
if (profiler[0]) logQuery(invokingResource, query, parseFloat(profiler[0].duration), parameters);
} else if (startTime) {
logQuery(invokingResource, query, performance.now() - startTime, parameters);
}
validateResultSet(invokingResource, query, result);
if (!cb) return parseResponse(type, result);
try {
cb(parseResponse(type, result));
} catch (err) {
if (typeof err === 'string') {
if (err.includes('SCRIPT ERROR:')) return console.log(err);
console.log(`^1SCRIPT ERROR in invoking resource ${invokingResource}: ${err}^0`);
}
}
} catch (err: any) {
logError(invokingResource, cb, isPromise, err, query, parameters, true);
}
};
@@ -0,0 +1,92 @@
import { getConnection } from './connection';
import { logError, logger, logQuery } from '../logger';
import { CFXCallback, CFXParameters, TransactionQuery } from '../types';
import { parseTransaction } from '../utils/parseTransaction';
import { setCallback } from '../utils/setCallback';
import { performance } from 'perf_hooks';
import { profileBatchStatements, runProfiler } from 'profiler';
const transactionError = (queries: { query: string; params?: CFXParameters }[], parameters: CFXParameters) => {
`${queries.map((query) => `${query.query} ${JSON.stringify(query.params || [])}`).join('\n')}\n${JSON.stringify(
parameters
)}`;
};
export const rawTransaction = async (
invokingResource: string,
queries: TransactionQuery,
parameters: CFXParameters,
cb?: CFXCallback,
isPromise?: boolean
) => {
let transactions;
cb = setCallback(parameters, cb);
try {
transactions = parseTransaction(queries, parameters);
} catch (err: any) {
return logError(invokingResource, cb, isPromise, err);
}
using connection = await getConnection();
if (!connection) return;
let response = false;
try {
const hasProfiler = await runProfiler(connection, invokingResource);
await connection.beginTransaction();
const transactionsLength = transactions.length;
for (let i = 0; i < transactionsLength; i++) {
const transaction = transactions[i];
const startTime = !hasProfiler && performance.now();
await connection.query(transaction.query, transaction.params);
if (hasProfiler && ((i > 0 && i % 100 === 0) || i === transactionsLength - 1)) {
await profileBatchStatements(connection, invokingResource, transactions, null, i < 100 ? 0 : i);
} else if (startTime) {
logQuery(invokingResource, transaction.query, performance.now() - startTime, transaction.params);
}
}
await connection.commit();
response = true;
} catch (err: any) {
await connection.rollback().catch(() => {});
const transactionErrorMessage = err.sql || transactionError(transactions, parameters);
const msg = `${invokingResource} was unable to complete a transaction!\n${transactionErrorMessage}\n${err.message}`;
console.error(msg);
TriggerEvent('oxmysql:transaction-error', {
query: transactionErrorMessage,
parameters: parameters,
message: err.message,
err: err,
resource: invokingResource,
});
if (typeof err === 'object' && err.message) delete err.sqlMessage;
logger({
level: 'error',
resource: invokingResource,
message: msg,
metadata: err,
});
}
if (cb)
try {
cb(response);
} catch (err) {
if (typeof err === 'string') {
if (err.includes('SCRIPT ERROR:')) return console.log(err);
console.log(`^1SCRIPT ERROR in invoking resource ${invokingResource}: ${err}^0`);
}
}
};
@@ -0,0 +1,52 @@
import { MySql, getConnection } from './connection';
import { logError } from '../logger';
import { CFXCallback, CFXParameters } from '../types';
import { parseArguments } from 'utils/parseArguments';
async function runQuery(conn: MySql | null, sql: string, values: CFXParameters) {
[sql, values] = parseArguments(sql, values);
try {
if (!conn) throw new Error(`Connection used by transaction timed out after 30 seconds.`);
return await conn.query(sql, values);
} catch (err: any) {
throw new Error(`Query: ${sql}\n${JSON.stringify(values)}\n${err.message}`);
}
}
export const startTransaction = async (
invokingResource: string,
queries: (...args: any[]) => Promise<boolean>,
cb?: CFXCallback,
isPromise?: boolean
) => {
using conn: MySql = await getConnection();
let response: boolean | null = false;
let closed = false;
if (!conn) return;
setTimeout(() => (closed = true), 30000);
try {
await conn.beginTransaction();
const commit = await queries((sql: string, values: CFXParameters) =>
runQuery(closed ? null : conn, sql, values)
);
if (closed) throw new Error(`Transaction has timed out after 30 seconds.`);
response = commit === false ? false : true;
if (!response) conn.rollback();
} catch (err: any) {
conn.rollback();
logError(invokingResource, cb, isPromise, err);
} finally {
closed = true;
}
return cb ? cb(response) : response;
};
@@ -0,0 +1,158 @@
import type { CFXCallback, CFXParameters, TransactionQuery } from './types';
import { rawQuery, rawExecute, rawTransaction, pool } from './database';
import { startTransaction } from 'database/startTransaction';
import { sleep } from 'utils/sleep';
import ghmatti from './compatibility/ghmattimysql';
import mysqlAsync from './compatibility/mysql-async';
import('./update');
const MySQL = {} as Record<string, Function>;
MySQL.isReady = () => {
return pool ? true : false;
};
MySQL.awaitConnection = async () => {
while (!pool) await sleep(0);
return true;
};
MySQL.query = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawQuery(null, invokingResource, query, parameters, cb, isPromise);
};
MySQL.single = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawQuery('single', invokingResource, query, parameters, cb, isPromise);
};
MySQL.scalar = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawQuery('scalar', invokingResource, query, parameters, cb, isPromise);
};
MySQL.update = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawQuery('update', invokingResource, query, parameters, cb, isPromise);
};
MySQL.insert = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawQuery('insert', invokingResource, query, parameters, cb, isPromise);
};
MySQL.transaction = (
queries: TransactionQuery,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawTransaction(invokingResource, queries, parameters, cb, isPromise);
};
MySQL.startTransaction = (
transactions: () => Promise<boolean>,
invokingResource = GetInvokingResource()
) => {
console.warn(`MySQL.startTransaction is "experimental" and may receive breaking changes.`)
return startTransaction(invokingResource, transactions, undefined, true);
};
MySQL.prepare = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawExecute(invokingResource, query, parameters, cb, isPromise, true);
};
MySQL.rawExecute = (
query: string,
parameters: CFXParameters,
cb: CFXCallback,
invokingResource = GetInvokingResource(),
isPromise?: boolean
) => {
rawExecute(invokingResource, query, parameters, cb, isPromise);
};
// provide the store export for compatibility (ghmatti/mysql-async); simply returns the query as-is
MySQL.store = (query: string, cb: Function) => {
cb(query);
};
// deprecated export names
MySQL.execute = MySQL.query;
MySQL.fetch = MySQL.query;
function provide(resourceName: string, method: string, cb: Function) {
on(`__cfx_export_${resourceName}_${method}`, (setCb: Function) => setCb(cb));
}
for (const key in MySQL) {
const exp = MySQL[key];
const async_exp = (query: string, parameters: CFXParameters, invokingResource = GetInvokingResource()) => {
return new Promise((resolve, reject) => {
MySQL[key](
query,
parameters,
(result: unknown, err: string) => {
if (err) return reject(new Error(err));
resolve(result);
},
invokingResource,
true
);
});
};
global.exports(key, exp);
// async_retval
global.exports(`${key}_async`, async_exp);
// deprecated aliases for async_retval
global.exports(`${key}Sync`, async_exp);
let alias = (ghmatti as any)[key];
if (alias) {
provide('ghmattimysql', alias, exp);
provide('ghmattimysql', `${alias}Sync`, async_exp);
}
alias = (mysqlAsync as any)[key];
if (alias) {
provide('mysql-async', alias, exp);
}
}
@@ -0,0 +1,205 @@
import { mysql_debug, mysql_log_size, mysql_slow_query_warning, mysql_ui } from '../config';
import type { CFXCallback, CFXParameters } from '../types';
import { dbVersion } from '../database';
let loggerResource = '';
let loggerService = GetConvar('mysql_logger_service', '');
if (loggerService) {
if (loggerService.startsWith('@')) {
const [resource, ...path] = loggerService.slice(1).split('/');
if (resource && path) {
loggerResource = resource;
loggerService = path.join('/');
}
} else loggerService = `logger/${loggerService}`;
}
export const logger =
(loggerService &&
new Function(LoadResourceFile(loggerResource || GetCurrentResourceName(), `${loggerService}.js`))()) ||
(() => {});
export function logError(
invokingResource: string,
cb: CFXCallback | undefined,
isPromise: boolean | undefined,
err: any | string = '', // i cbf typing the error right now
query?: string,
parameters?: CFXParameters,
includeParameters?: boolean
) {
const message = typeof err === 'object' ? err.message : err.replace(/SCRIPT ERROR: citizen:[\w\/\.]+:\d+[:\s]+/, '');
const output = `${invokingResource} was unable to execute a query!${query ? `\n${`Query: ${query}`}` : ''}${
includeParameters ? `\n${JSON.stringify(parameters)}` : ''
}\n${message}`;
TriggerEvent('oxmysql:error', {
query: query,
parameters: parameters,
message: message,
err: err,
resource: invokingResource,
});
if (typeof err === 'object' && err.message) delete err.sqlMessage;
logger({
level: 'error',
resource: invokingResource,
message: message,
metadata: err,
});
if (cb && isPromise) {
try {
return cb(null, output);
} catch (e) {}
return;
}
console.error(output);
}
interface QueryData {
date: number;
query: string;
executionTime: number;
slow?: boolean;
}
type QueryLog = Record<string, QueryData[]>;
const logStorage: QueryLog = {};
export const logQuery = (
invokingResource: string,
query: string,
executionTime: number,
parameters?: CFXParameters
) => {
if (
executionTime >= mysql_slow_query_warning ||
(mysql_debug && (!Array.isArray(mysql_debug) || mysql_debug.includes(invokingResource)))
) {
console.log(
`${dbVersion} ^3${invokingResource} took ${executionTime.toFixed(4)}ms to execute a query!\n${query}${
parameters ? ` ${JSON.stringify(parameters)}` : ''
}^0`
);
}
if (!mysql_ui) return;
if (!logStorage[invokingResource]) logStorage[invokingResource] = [];
else if (logStorage[invokingResource].length > mysql_log_size) logStorage[invokingResource].splice(0, 1);
logStorage[invokingResource].push({
query,
executionTime,
date: Date.now(),
slow: executionTime >= mysql_slow_query_warning ? true : undefined,
});
};
RegisterCommand(
'mysql',
(source: number) => {
if (!mysql_ui) return;
if (source < 1) {
// source is 0 when received from the server
console.log('^3This command cannot run server side^0');
return;
}
let totalQueries: number = 0;
let totalTime = 0;
let slowQueries = 0;
let chartData: { labels: string[]; data: { queries: number; time: number }[] } = { labels: [], data: [] };
for (const resource in logStorage) {
const queries = logStorage[resource];
let totalResourceTime = 0;
totalQueries += queries.length;
totalTime += queries.reduce((totalTime, query) => (totalTime += query.executionTime), 0);
slowQueries += queries.reduce((slowQueries, query) => (slowQueries += query.slow ? 1 : 0), 0);
totalResourceTime += queries.reduce((totalResourceTime, query) => (totalResourceTime += query.executionTime), 0);
chartData.labels.push(resource);
chartData.data.push({ queries: queries.length, time: totalResourceTime });
}
emitNet(`oxmysql:openUi`, source, {
resources: Object.keys(logStorage),
totalQueries,
slowQueries,
totalTime,
chartData,
});
},
true
);
const sortQueries = (queries: QueryData[], sort: { id: 'query' | 'executionTime'; desc: boolean }) => {
const sortedQueries = [...queries].sort((a, b) => {
switch (sort.id) {
case 'query':
return a.query > b.query ? 1 : -1;
case 'executionTime':
return a.executionTime - b.executionTime;
default:
return 0;
}
});
return sort.desc ? sortedQueries.reverse() : sortedQueries;
};
onNet(
`oxmysql:fetchResource`,
(data: {
resource: string;
pageIndex: number;
search: string;
sortBy?: { id: 'query' | 'executionTime'; desc: boolean }[];
}) => {
if (typeof data.resource !== 'string' || !IsPlayerAceAllowed(source as unknown as string, 'command.mysql')) return;
if (data.search) data.search = data.search.toLowerCase();
const resourceLog = data.search
? logStorage[data.resource].filter((q) => q.query.toLowerCase().includes(data.search))
: logStorage[data.resource];
const sort = data.sortBy && data.sortBy.length > 0 ? data.sortBy[0] : false;
const startRow = data.pageIndex * 10;
const endRow = startRow + 10;
const queries = sort ? sortQueries(resourceLog, sort).slice(startRow, endRow) : resourceLog.slice(startRow, endRow);
const pageCount = Math.ceil(resourceLog.length / 10);
if (!queries) return;
let resourceTime = 0;
let resourceSlowQueries = 0;
const resourceQueriesCount = resourceLog.length;
for (let i = 0; i < resourceQueriesCount; i++) {
const query = resourceLog[i];
resourceTime += query.executionTime;
if (query.slow) resourceSlowQueries += 1;
}
emitNet(`oxmysql:loadResource`, source, {
queries,
pageCount,
resourceQueriesCount,
resourceSlowQueries,
resourceTime,
});
}
);
@@ -0,0 +1,64 @@
import { mysql_debug } from 'config';
import type { MySql } from 'database';
import { logQuery } from 'logger';
import type { RowDataPacket } from 'mysql2';
import type { CFXParameters } from 'types';
const profilerStatements = [
'SET profiling_history_size = 0',
'SET profiling = 0',
'SET profiling_history_size = 100',
'SET profiling = 1',
];
/**
* Executes MySQL queries to enable accurate query profiling results when `mysql_debug` is enabled.
*/
export async function runProfiler(connection: MySql, invokingResource: string) {
if (!mysql_debug) return;
if (Array.isArray(mysql_debug) && !mysql_debug.includes(invokingResource)) return;
for (const statement of profilerStatements) await connection.query(statement);
return true;
}
/**
* Fetches the duration of the last 100 queries and resets profiling history.
*/
export async function profileBatchStatements(
connection: MySql,
invokingResource: string,
query: string | { query: string; params?: CFXParameters }[],
parameters: CFXParameters | null,
offset: number
) {
const profiler = <RowDataPacket[]>(
await connection.query(
'SELECT FORMAT(SUM(DURATION) * 1000, 4) AS `duration` FROM INFORMATION_SCHEMA.PROFILING GROUP BY QUERY_ID'
)
);
for (const statement of profilerStatements) await connection.query(statement);
if (profiler.length === 0) return;
if (typeof query === 'string' && parameters) {
for (let i = 0; i < profiler.length; i++) {
logQuery(invokingResource, query, parseFloat(profiler[i].duration), parameters[offset + i]);
}
return;
}
if (typeof query === 'object') {
for (let i = 0; i < profiler.length; i++) {
const transaction = query[offset + i];
if (!transaction) break;
logQuery(invokingResource, transaction.query, parseFloat(profiler[i].duration), transaction.params);
}
}
}
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"baseUrl": ".",
"noImplicitAny": true,
"module": "es2020",
"target": "es2021",
"lib": ["es2021", "esnext.disposable"],
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"allowUnreachableCode": false,
"strictFunctionTypes": true,
"moduleResolution": "bundler",
"noImplicitThis": true,
"noUnusedLocals": true,
"strict": true,
"types": ["@types/node", "@citizenfx/server"]
}
}
@@ -0,0 +1,23 @@
import type { OkPacket, ResultSetHeader, RowDataPacket, ProcedureCallPacket } from 'mysql2';
export type QueryResponse =
| OkPacket
| ResultSetHeader
| ResultSetHeader[]
| RowDataPacket[]
| RowDataPacket[][]
| OkPacket[]
| ProcedureCallPacket;
export type QueryType = 'execute' | 'insert' | 'update' | 'scalar' | 'single' | null;
export type TransactionQuery = {
query: string | string[];
parameters?: CFXParameters;
values?: CFXParameters;
};
// working with this type is impossible but at least we can pretend to be strictly typed
export type CFXParameters = any[];
export type CFXCallback = (result: unknown, err?: string) => void;
@@ -0,0 +1,37 @@
import fetch from 'node-fetch';
(() => {
if (GetConvarInt('mysql_versioncheck', 1) === 0) return;
const resourceName = GetCurrentResourceName();
const currentVersion = GetResourceMetadata(resourceName, 'version', 0)?.match(/(\d+)\.(\d+)\.(\d+)/);
if (!currentVersion) return;
setTimeout(async () => {
const response = await fetch(`https://api.github.com/repos/overextended/oxmysql/releases/latest`).catch((err) => {
console.warn(`Failed to retrieve latest version of oxmysql (${err.code}).`);
});
if (response?.status !== 200) return;
const release = (await response.json()) as any;
if (release.prerelease) return;
const latestVersion = release.tag_name.match(/(\d+)\.(\d+)\.(\d+)/);
if (!latestVersion || latestVersion[0] === currentVersion[0]) return;
for (let i = 1; i < currentVersion.length; i++) {
const current = parseInt(currentVersion[i]);
const latest = parseInt(latestVersion[i]);
if (current !== latest) {
if (current < latest)
return console.log(
`^3An update is available for ${resourceName} (current version: ${currentVersion[0]})\r\n${release.html_url}^0`
);
else break;
}
}
}, 1000);
})();
@@ -0,0 +1,42 @@
import type { CFXParameters } from '../types';
import { convertNamedPlaceholders } from '../config';
export const parseArguments = (query: string, parameters?: CFXParameters): [string, CFXParameters] => {
if (typeof query !== 'string') throw new Error(`Expected query to be a string but received ${typeof query} instead.`);
if (convertNamedPlaceholders && parameters && typeof parameters === 'object' && !Array.isArray(parameters))
if (query.includes(':') || query.includes('@')) {
[query, parameters] = convertNamedPlaceholders(query, parameters);
}
if (!parameters || typeof parameters === 'function') parameters = [];
const placeholders = query.match(/\?(?!\?)/g)?.length ?? 0;
if (parameters && !Array.isArray(parameters)) {
let arr: unknown[] = [];
for (let i = 0; i < placeholders; i++) {
arr[i] = parameters[i + 1] ?? null;
}
parameters = arr;
} else {
if (placeholders) {
if (parameters.length === 0) {
for (let i = 0; i < placeholders; i++) parameters[i] = null;
return [query, parameters];
}
const diff = placeholders - parameters.length;
if (diff > 0) {
for (let i = 0; i < diff; i++) parameters[placeholders + i] = null;
} else if (diff < 0) {
throw new Error(`Expected ${placeholders} parameters, but received ${parameters.length}.`);
}
}
}
return [query, parameters];
};
@@ -0,0 +1,70 @@
import { CFXParameters } from '../types';
export const executeType = (query: string) => {
if (typeof query !== 'string') throw new Error(`Expected query to be a string but received ${typeof query} instead.`);
switch (query.substring(0, query.indexOf(' '))) {
case 'INSERT':
return 'insert';
case 'UPDATE':
return 'update';
case 'DELETE':
return 'update';
default:
return null;
}
};
export const parseExecute = (placeholders: number, parameters: CFXParameters) => {
const parametersType = typeof parameters;
if (!parameters || parametersType !== 'object') return [];
if (!Array.isArray(parameters)) {
if (typeof parameters === 'object') {
const arr: unknown[] = [];
Object.entries(parameters).forEach((entry) => (arr[parseInt(entry[0]) - 1] = entry[1]));
parameters = arr;
} else throw new Error(`Parameters expected an array but received ${typeof parameters} instead`);
}
if (!parameters.every(Array.isArray)) {
if (parameters.every((item) => typeof item === 'object')) {
const arr: unknown[][] = [];
parameters.forEach((value, index) => {
arr[index] = new Array(placeholders);
if (!Array.isArray(value)) {
Object.entries(value).forEach((entry) => {
arr[index][parseInt(entry[0]) - 1] = entry[1];
});
} else arr[index] = parameters[index];
for (let i = 0; i < placeholders; i++) {
if (arr[index][i] === undefined) arr[index][i] = null;
}
});
parameters = arr;
} else parameters = [[...parameters]];
}
return parameters;
};
export const parseValues = (placeholders: number, parameters: CFXParameters) => {
if (!Array.isArray(parameters)) {
if (typeof parameters === 'object') {
const arr: unknown[] = [];
Object.entries(parameters).forEach((entry) => (arr[parseInt(entry[0]) - 1] = entry[1]));
parameters = arr;
} else throw new Error(`Parameters expected an array but received ${typeof parameters} instead`);
} else if (placeholders > parameters.length) {
for (let i = parameters.length; i < placeholders; i++) {
parameters[i] = null;
}
}
return parameters;
};
@@ -0,0 +1,22 @@
import { ResultSetHeader, RowDataPacket } from 'mysql2';
import type { QueryResponse, QueryType } from '../types';
export const parseResponse = (type: QueryType, result: QueryResponse): any => {
switch (type) {
case 'insert':
return (result as ResultSetHeader)?.insertId ?? null;
case 'update':
return (result as ResultSetHeader)?.affectedRows ?? null;
case 'single':
return (result as RowDataPacket[])?.[0] ?? null;
case 'scalar':
const row = (result as RowDataPacket[])?.[0];
return (row && Object.values(row)[0]) ?? null;
default:
return result ?? null;
}
};
@@ -0,0 +1,34 @@
import { CFXParameters, TransactionQuery } from '../types';
import { parseArguments } from './parseArguments';
const isTransactionQuery = (query: TransactionQuery | string): query is TransactionQuery =>
(query as TransactionQuery).query !== undefined;
export const parseTransaction = (queries: TransactionQuery, parameters: CFXParameters) => {
if (!Array.isArray(queries)) throw new Error(`Transaction queries must be array, received '${typeof queries}'.`);
if (!parameters || typeof parameters === 'function') parameters = [];
if (Array.isArray(queries[0])) {
const transactions = queries.map((query) => {
if (typeof query[1] !== 'object')
throw new Error(`Transaction parameters must be array or object, received '${typeof query[1]}'.`);
const [parsedQuery, parsedParameters] = parseArguments(query[0], query[1]);
return { query: parsedQuery, params: parsedParameters };
});
return transactions;
}
const transactions = queries.map((query) => {
const [parsedQuery, parsedParameters] = parseArguments(
isTransactionQuery(query) ? query.query : query,
isTransactionQuery(query) ? query.parameters || query.values : parameters
);
return { query: parsedQuery, params: parsedParameters };
});
return transactions;
};
@@ -0,0 +1,5 @@
const resourceName = GetCurrentResourceName();
export async function scheduleTick() {
ScheduleResourceTick(resourceName);
}
@@ -0,0 +1,6 @@
import type { CFXCallback, CFXParameters } from '../types';
export const setCallback = (parameters?: CFXParameters | CFXCallback, cb?: CFXCallback) => {
if (cb && typeof cb === 'function') return cb;
if (parameters && typeof parameters === 'function') return parameters;
};
@@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -0,0 +1,61 @@
import type { TypeCastField, TypeCastNext } from 'mysql2/promise';
const BINARY_CHARSET = 63;
/**
* node-mysql2 v3.9.0 introduced (breaking) typecasting for execute methods.
*/
export function typeCastExecute(field: TypeCastField, next: TypeCastNext) {
switch (field.type) {
case 'DATETIME':
case 'DATETIME2':
case 'TIMESTAMP':
case 'TIMESTAMP2':
case 'NEWDATE': {
const value = field.string();
return value ? new Date(value).getTime() : null;
}
case 'DATE': {
const value = field.string();
return value ? new Date(value + ' 00:00:00').getTime() : null;
}
default:
return next();
}
}
/**
* mysql-async compatible typecasting.
*/
export function typeCast(field: TypeCastField, next: TypeCastNext) {
switch (field.type) {
case 'DATETIME':
case 'DATETIME2':
case 'TIMESTAMP':
case 'TIMESTAMP2':
case 'NEWDATE': {
const value = field.string();
return value ? new Date(value).getTime() : null;
}
case 'DATE': {
const value = field.string();
return value ? new Date(value + ' 00:00:00').getTime() : null;
}
case 'TINY':
return field.length === 1 ? field.string() === '1' : next();
case 'BIT':
return field.length === 1 ? field.buffer()?.[0] === 1 : field.buffer()?.[0];
case 'TINY_BLOB':
case 'MEDIUM_BLOB':
case 'LONG_BLOB':
case 'BLOB':
if (field.charset === BINARY_CHARSET) {
const value = field.buffer();
if (value === null) return [value];
return [...value];
}
return field.string();
default:
return next();
}
}
@@ -0,0 +1,22 @@
import { OkPacket, ProcedureCallPacket, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
const oversizedResultSet = GetConvarInt('mysql_resultset_warning', 1000);
export default function (
invokingResource: string,
query: string,
rows:
| OkPacket
| ResultSetHeader
| ResultSetHeader[]
| RowDataPacket[]
| RowDataPacket[][]
| OkPacket[]
| ProcedureCallPacket
) {
const length = Array.isArray(rows) ? rows.length : 0;
if (length < oversizedResultSet) return;
console.warn(`${invokingResource} executed a query with an oversized result set (${length} results)!\n${query}`);
}
@@ -0,0 +1,24 @@
RegisterNetEvent('oxmysql:openUi', function(data)
SendNUIMessage({
action = 'openUI',
data = data
})
SetNuiFocus(true, true)
end)
RegisterNUICallback('exit', function(_, cb)
cb(true)
SetNuiFocus(false, false)
end)
RegisterNUICallback('fetchResource', function(data, cb)
TriggerServerEvent('oxmysql:fetchResource', data)
cb(true)
end)
RegisterNetEvent('oxmysql:loadResource', function(data)
SendNUIMessage({
action = 'loadResource',
data = data
})
end)
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
build
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,11 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true,
"trailingComma": "es5",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": false
}
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Svelte + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,37 @@
{
"name": "oxmysql",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite",
"start:game": "vite build --watch",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.5.3",
"@tsconfig/svelte": "^3.0.0",
"svelte": "^3.59.2",
"svelte-check": "^2.10.3",
"tslib": "^2.7.0",
"typescript": "^4.9.5",
"vite": "^4.5.5"
},
"dependencies": {
"@tabler/icons-svelte": "^2.47.0",
"@tanstack/svelte-table": "8.8.0",
"autoprefixer": "^10.4.20",
"chart.js": "^4.4.4",
"postcss": "^8.4.47",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1",
"prettier-plugin-tailwindcss": "^0.2.8",
"svelte-chartjs": "^3.1.5",
"svelte-floating-ui": "^1.5.9",
"svelte-portal": "2.2.0",
"tailwindcss": "^3.4.13",
"tinro": "^0.6.12"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,85 @@
<script lang="ts">
import { Route, router } from 'tinro';
import Resource from './pages/resource/Resource.svelte';
import Root from './pages/root/Root.svelte';
import { useNuiEvent } from './utils/useNuiEvent';
import { resources, generalData, chartData } from './store';
import { debugData } from './utils/debugData';
import { visible } from './store';
import { scale } from 'svelte/transition';
import { fetchNui } from './utils/fetchNui';
interface OpenData {
resources: string[];
totalQueries: number;
slowQueries: number;
totalTime: number;
chartData: {
labels: string[];
data: { queries: number; time: number }[];
};
}
router.mode.hash();
router.goto('/');
useNuiEvent('openUI', (data: OpenData) => {
$visible = true;
$resources = data.resources;
$generalData = {
queries: data.totalQueries,
slowQueries: data.slowQueries,
timeQuerying: data.totalTime,
};
$chartData = {
labels: data.chartData.labels,
data: data.chartData.data,
};
});
debugData<OpenData>([
{
action: 'openUI',
data: {
resources: ['ox_core', 'oxmysql', 'ox_inventory', 'ox_doorlock', 'ox_lib', 'ox_vehicleshop', 'ox_target'],
slowQueries: 13,
totalQueries: 332,
totalTime: 230123,
chartData: {
labels: ['oxmysql', 'ox_core', 'ox_inventory', 'ox_doorlock'],
data: [
{ queries: 25, time: 133 },
{ queries: 5, time: 12 },
{ queries: 3, time: 2 },
{ queries: 72, time: 133 },
],
},
},
},
]);
const handleESC = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
$visible = false;
fetchNui('exit');
};
$: $visible ? window.addEventListener('keydown', handleESC) : window.removeEventListener('keydown', handleESC);
</script>
{#if $visible}
<main
transition:scale={{ start: 0.95, duration: 150 }}
class="font-main flex h-full w-full items-center justify-center"
>
<div class="bg-dark-800 flex h-[700px] w-[1200px] rounded-md text-white">
<Route path="/">
<Root />
</Route>
<Route path="/:resource">
<Resource />
</Route>
</div>
</main>
{/if}
@@ -0,0 +1,31 @@
@import url('https://use.typekit.net/qgr5ebd.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
}
#app {
width: 100vw;
height: 100vh;
}
::-webkit-scrollbar {
width: 2px;
}
::-webkit-scrollbar-track {
background: #25262b;
}
::-webkit-scrollbar-thumb {
background: #909296;
}
::-webkit-scrollbar-thumb:hover {
background: #a6a7ab;
}
@@ -0,0 +1,19 @@
import './index.css';
import App from './App.svelte';
import { isEnvBrowser } from './utils/misc';
const app = new App({
target: document.getElementById('app'),
});
if (isEnvBrowser()) {
const root = document.getElementById('app');
// https://i.imgur.com/iPTAdYV.png - Night time img
root!.style.backgroundImage = 'url("https://i.imgur.com/3pzRj9n.png")';
root!.style.backgroundSize = 'cover';
root!.style.backgroundRepeat = 'no-repeat';
root!.style.backgroundPosition = 'center';
}
export default app;
@@ -0,0 +1,67 @@
<script lang="ts">
import { fetchNui } from '../../utils/fetchNui';
import Pagination from './components/Pagination.svelte';
import QueryTable from './components/QueryTable.svelte';
import ResourceHeader from './components/ResourceHeader.svelte';
import { meta } from 'tinro';
import { useNuiEvent } from '../../utils/useNuiEvent';
import { queries, resourceData, type QueryData } from '../../store';
import { debugData } from '../../utils/debugData';
import { onDestroy } from 'svelte';
import { filterData } from '../../store';
import QuerySearch from './components/QuerySearch.svelte';
import IconSearch from '@tabler/icons-svelte/dist/svelte/icons/IconSearch.svelte';
let maxPage = 0;
onDestroy(() => {
$queries = [];
$filterData.page = 0;
});
interface ResourceData {
queries: QueryData[];
pageCount: number;
resourceQueriesCount: number;
resourceSlowQueries: number;
resourceTime: number;
}
debugData<ResourceData>([
{
action: 'loadResource',
data: {
queries: [
{ query: 'SELECT * FROM users WHERE ID = 1', executionTime: 3, slow: false, date: Date.now() },
{ query: 'SELECT * FROM users WHERE ID = 1', executionTime: 23, slow: true, date: Date.now() },
{ query: 'SELECT * FROM users WHERE ID = 1', executionTime: 15, slow: false, date: Date.now() },
{ query: 'SELECT * FROM users WHERE ID = 1', executionTime: 122, slow: true, date: Date.now() },
],
resourceQueriesCount: 3,
resourceSlowQueries: 2,
resourceTime: 1342,
pageCount: 3,
},
},
]);
// I miss callbacks :(
useNuiEvent('loadResource', (data: ResourceData) => {
maxPage = data.pageCount;
$queries = data.queries;
$resourceData = {
resourceQueriesCount: data.resourceQueriesCount,
resourceSlowQueries: data.resourceSlowQueries,
resourceTime: data.resourceTime,
};
});
</script>
<div class="flex w-full flex-col justify-between">
<div>
<ResourceHeader />
<QuerySearch icon={IconSearch} />
<QueryTable />
</div>
<Pagination {maxPage} />
</div>
@@ -0,0 +1,44 @@
<script lang="ts">
import IconChevronLeft from '@tabler/icons-svelte/dist/svelte/icons/IconChevronLeft.svelte';
import IconChevronRight from '@tabler/icons-svelte/dist/svelte/icons/IconChevronRight.svelte';
import IconChevronsLeft from '@tabler/icons-svelte/dist/svelte/icons/IconChevronsLeft.svelte';
import IconChevronsRight from '@tabler/icons-svelte/dist/svelte/icons/IconChevronsRight.svelte';
import { onDestroy } from 'svelte';
import { filterData } from '../../../store';
export let maxPage: number;
onDestroy(() => (maxPage = 0));
</script>
<div class="flex items-center justify-center gap-6 pb-5">
<button
disabled={$filterData.page === 0}
on:click={() => ($filterData.page = 0)}
class="bg-dark-600 disabled:bg-dark-300 disabled:text-dark-400 text-dark-100 hover:bg-dark-500 rounded-md border-[1px] border-transparent p-2 outline-none hover:text-white focus-visible:border-cyan-600 focus-visible:text-white active:translate-y-[3px] disabled:cursor-not-allowed"
>
<IconChevronsLeft />
</button>
<button
disabled={$filterData.page === 0}
on:click={() => $filterData.page--}
class="bg-dark-600 disabled:bg-dark-300 disabled:text-dark-400 text-dark-100 hover:bg-dark-500 rounded-md border-[1px] border-transparent p-2 outline-none hover:text-white focus-visible:border-cyan-600 focus-visible:text-white active:translate-y-[3px] disabled:cursor-not-allowed"
>
<IconChevronLeft />
</button>
<p>Page {$filterData.page + 1} of {maxPage}</p>
<button
disabled={$filterData.page >= maxPage - 1}
on:click={() => $filterData.page++}
class="bg-dark-600 disabled:bg-dark-300 disabled:text-dark-400 text-dark-100 hover:bg-dark-500 rounded-md border-[1px] border-transparent p-2 outline-none hover:text-white focus-visible:border-cyan-600 focus-visible:text-white active:translate-y-[3px] disabled:cursor-not-allowed"
>
<IconChevronRight />
</button>
<button
disabled={$filterData.page === maxPage - 1}
on:click={() => ($filterData.page = maxPage - 1)}
class="bg-dark-600 disabled:bg-dark-300 disabled:text-dark-400 text-dark-100 hover:bg-dark-500 rounded-md border-[1px] border-transparent p-2 outline-none hover:text-white focus-visible:border-cyan-600 focus-visible:text-white active:translate-y-[3px] disabled:cursor-not-allowed"
>
<IconChevronsRight />
</button>
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import { filterData } from '../../../store/resource';
let value = '';
$: {
$filterData.search = value;
$filterData.page = 0;
}
export let icon: ConstructorOfATypedSvelteComponent;
</script>
<div
class="bg-dark-600 m-4 mt-0 flex items-center rounded-md border-[1px] border-transparent p-2 outline-none transition-all duration-100 focus-within:border-cyan-600"
>
<div class="pr-2">
<svelte:component this={icon} class="text-dark-300" />
</div>
<input type="text" bind:value class="w-full bg-transparent outline-none" placeholder="Search queries..." />
</div>
@@ -0,0 +1,138 @@
<script lang="ts">
import IconChevronDown from '@tabler/icons-svelte/dist/svelte/icons/IconChevronDown.svelte';
import IconChevronUp from '@tabler/icons-svelte/dist/svelte/icons/IconChevronUp.svelte';
import { queries, type QueryData } from '../../../store';
import {
createSvelteTable,
getCoreRowModel,
type TableOptions,
type ColumnDef,
flexRender,
type SortingState,
} from '@tanstack/svelte-table';
import { writable } from 'svelte/store';
import { meta } from 'tinro';
import { fetchNui } from '../../../utils/fetchNui';
import { filterData } from '../../../store';
import QueryTooltip from './QueryTooltip.svelte';
const route = meta();
const columns: ColumnDef<QueryData, number>[] = [
{
accessorKey: 'query',
header: 'Query',
cell: (info) => info.getValue(),
enableSorting: true,
},
{
accessorKey: 'executionTime',
header: 'Time (ms)',
cell: (info) => info.getValue().toFixed(4),
enableSorting: true,
},
];
let sorting: SortingState = [];
const setSorting = (updater) => {
if (updater instanceof Function) {
sorting = updater(sorting);
} else {
sorting = updater;
}
options.update((old) => ({
...old,
state: {
...old.state,
sorting,
},
}));
};
const options = writable<TableOptions<QueryData>>({
data: $queries,
columns,
manualPagination: true,
manualSorting: true,
pageCount: -1,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
state: {
sorting,
},
});
$: options.update((prev) => ({ ...prev, data: $queries }));
const table = createSvelteTable(options);
let timer: NodeJS.Timeout;
$: {
clearTimeout(timer);
timer = setTimeout(() => {
fetchNui('fetchResource', {
resource: route.params.resource,
pageIndex: $filterData.page,
search: $filterData.search,
sortBy: sorting,
});
}, 300);
}
</script>
<div class="px-4">
<table class="w-full">
<thead class="bg-dark-600">
{#each $table.getHeaderGroups() as headerGroup}
<tr>
{#each headerGroup.headers as header}
<th class={`bg-dark-600 select-none p-1 ${header.id === 'executionTime' ? 'w-1/4' : 'w-3/4'}`}>
{#if !header.isPlaceholder}
<button
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
class="flex w-full items-center justify-center gap-1"
on:click={header.column.getToggleSortingHandler()}
>
<svelte:component this={flexRender(header.column.columnDef.header, header.getContext())} />
{#if header.column.getIsSorted() === 'asc'}
<IconChevronUp />
{:else if header.column.getIsSorted() === 'desc'}
<IconChevronDown />
{/if}
</button>
{/if}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each $table.getRowModel().rows as row}
<tr>
{#each row.getVisibleCells() as cell}
<QueryTooltip
content={cell.getValue()}
let:floatingRef
let:displayTooltip
let:hideTooltip
disabled={cell.column.id !== 'query'}
>
<td
use:floatingRef
on:mouseenter={displayTooltip}
on:mouseleave={hideTooltip}
class={`${cell.column.id === 'executionTime' && 'text-center'} bg-dark-700 p-2 ${
row.original.slow && 'text-yellow-500'
} max-w-[200px] truncate`}
>
<svelte:component this={flexRender(cell.column.columnDef.cell, cell.getContext())} />
</td>
</QueryTooltip>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { offset, flip, shift } from 'svelte-floating-ui/dom';
import { createFloatingActions } from 'svelte-floating-ui';
import { fade } from 'svelte/transition';
import Portal from 'svelte-portal';
export let content: unknown;
export let disabled: boolean;
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
placement: 'top',
middleware: [offset(6), flip(), shift()],
});
let display = false;
let timer: NodeJS.Timer;
const displayTooltip = () => {
if (disabled) return;
clearTimeout(timer);
timer = setTimeout(() => {
display = true;
}, 300);
};
const hideTooltip = () => {
if (disabled) return;
clearTimeout(timer);
display = false;
};
</script>
<slot {floatingRef} {displayTooltip} {hideTooltip} />
{#if display && !disabled}
<Portal target="body">
<div
transition:fade={{ duration: 150 }}
use:floatingContent
class="absolute p-2 text-sm bg-dark-50 text-dark-400 rounded-md max-w-xl font-main"
>
{content}
</div>
</Portal>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import IconChevronLeft from '@tabler/icons-svelte/dist/svelte/icons/IconChevronLeft.svelte';
import { meta, router } from 'tinro';
import { resourceData } from '../../../store';
const route = meta();
</script>
<div class="p-4 grid grid-flow-col grid-cols-3 items-center ">
<button
on:click={() => router.goto('/')}
class="flex p-2 w-12 bg-dark-600 text-dark-100 hover:text-white rounded-md justify-center items-center hover:bg-dark-500 outline-none border-[1px] border-transparent focus-visible:border-cyan-600"
>
<IconChevronLeft />
</button>
<p class="text-center text-lg">{route.params.resource}</p>
<div class="text-end text-dark-100 flex flex-col text-xs">
<p>Queries: {$resourceData.resourceQueriesCount}</p>
<p>Time: {$resourceData.resourceTime.toFixed(4)} ms</p>
<p class="text-yellow-500">Slow queries: {$resourceData.resourceSlowQueries}</p>
</div>
</div>
@@ -0,0 +1,45 @@
<script lang="ts">
import Search from './components/Search.svelte';
import IconFileAnalytics from '@tabler/icons-svelte/dist/svelte/icons/IconFileAnalytics.svelte';
import IconSearch from '@tabler/icons-svelte/dist/svelte/icons/IconSearch.svelte';
import IconSourceCode from '@tabler/icons-svelte/dist/svelte/icons/IconSourceCode.svelte';
import { router } from 'tinro';
import { search, filteredResources, generalData } from '../../store';
import Chart from './components/Chart.svelte';
</script>
<div class="p-2 w-full h-full flex justify-between gap-2">
<div class="bg-dark-700 p-4 pr-0 flex flex-col w-2/3 rounded-md">
<div class="pr-4 flex justify-between items-center">
<div class="flex gap-3 items-center ">
<p class="text-2xl">Resources</p>
<IconSourceCode />
</div>
<Search icon={IconSearch} bind:value={$search} />
</div>
<div class="flex flex-col gap-3 mt-6 overflow-y-auto pr-4">
{#each $filteredResources as resource}
<button
on:click={() => router.goto(`/${resource}`)}
class="bg-dark-600 p-3 border-[1px] outline-none border-transparent focus-visible:border-cyan-600 text-left hover:bg-dark-400 rounded-md"
>
{resource}
</button>
{/each}
</div>
</div>
<div class="bg-dark-700 p-4 flex flex-col justify-between w-1/3 rounded-md">
<div class="flex flex-col">
<div class="flex gap-3 items-center mb-4">
<p class="text-2xl">General data</p>
<IconFileAnalytics />
</div>
<div class="flex flex-col text-dark-50">
<p>Queries: {$generalData.queries}</p>
<p>Time querying: {$generalData.timeQuerying.toFixed(4)} ms</p>
<p class="text-yellow-500">Slow queries: {$generalData.slowQueries}</p>
</div>
</div>
<Chart />
</div>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Pie } from 'svelte-chartjs';
import { Chart as ChartJS, Title, Tooltip, ArcElement, CategoryScale, Colors, type TooltipItem } from 'chart.js';
import { chartData } from '../../../store';
ChartJS.register(Title, Tooltip, ArcElement, CategoryScale, Colors);
</script>
<Pie
class="self-center"
width={256}
height={256}
data={{
labels: $chartData.labels,
datasets: [
{
// @ts-ignore
data: $chartData.data,
},
],
}}
options={{
maintainAspectRatio: false,
responsive: false,
parsing: {
key: 'time',
},
animation: false,
plugins: {
tooltip: {
callbacks: {
label: (context) => {
// @ts-ignore
return `Queries: ${context.raw.queries}, Time: ${context.raw.time.toFixed(4)} ms`;
},
},
},
},
}}
/>
@@ -0,0 +1,13 @@
<script lang="ts">
export let icon: ConstructorOfATypedSvelteComponent;
export let value: string;
</script>
<div
class="p-2 flex items-center outline-none border-[1px] border-transparent transition-all duration-100 focus-within:border-cyan-600 rounded-md bg-dark-600"
>
<div class="pr-2">
<svelte:component this={icon} class="text-dark-300" />
</div>
<input type="text" bind:value class="bg-transparent outline-none w-full" placeholder="Search resources..." />
</div>
@@ -0,0 +1,34 @@
import { derived, writable } from 'svelte/store';
export const visible = writable(false);
export const search = writable('');
let timeout: NodeJS.Timeout;
export const debouncedSearch = derived(search, (value, set: (value: string) => void) => {
timeout = setTimeout(() => set(value), 500);
return () => clearTimeout(timeout);
});
export const resources = writable<string[]>([]);
export const filteredResources = derived(
[resources, debouncedSearch],
([$resources, $debouncedSearch], set: (value: string[]) => void) => {
if ($debouncedSearch === '' || !$debouncedSearch) return set($resources);
const query = $debouncedSearch.toLowerCase();
return set($resources.filter((resource) => resource.toLowerCase().includes(query)));
}
);
export const generalData = writable({
queries: 0,
timeQuerying: 0,
slowQueries: 0,
});
export const chartData = writable<{ labels: string[]; data: { queries: number; time: number }[] }>({
labels: [],
data: [],
});
@@ -0,0 +1,2 @@
export * from './general';
export * from './resource';
@@ -0,0 +1,22 @@
import { writable } from 'svelte/store';
export interface QueryData {
date: number;
query: string;
executionTime: number;
slow?: boolean;
}
export const queries = writable<QueryData[]>([]);
export const resourceData = writable<{
resourceQueriesCount: number;
resourceSlowQueries: number;
resourceTime: number;
}>({
resourceQueriesCount: 0,
resourceSlowQueries: 0,
resourceTime: 0,
});
export const filterData = writable({ search: '', page: 0 });
@@ -0,0 +1,30 @@
import { isEnvBrowser } from './misc';
interface DebugEvent<T = any> {
action: string;
data: T;
}
/**
* Emulates dispatching an event using SendNuiMessage in the lua scripts.
* This is used when developing in browser
*
* @param events - The event you want to cover
* @param timer - How long until it should trigger (ms)
*/
export const debugData = <P>(events: DebugEvent<P>[], timer = 1000): void => {
if (isEnvBrowser()) {
for (const event of events) {
setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
action: event.action,
data: event.data,
},
})
);
}, timer);
}
}
};
@@ -0,0 +1,24 @@
/**
* @param eventName - The endpoint eventname to target
* @param data - Data you wish to send in the NUI Callback
*
* @return returnData - A promise for the data sent back by the NuiCallbacks CB argument
*/
export async function fetchNui<T = any>(eventName: string, data: unknown = {}): Promise<T> {
const options = {
method: 'post',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(data),
};
const resourceName = (window as any).GetParentResourceName
? (window as any).GetParentResourceName()
: 'nui-frame-app';
const resp = await fetch(`https://${resourceName}/${eventName}`, options);
return await resp.json();
}
@@ -0,0 +1 @@
export const isEnvBrowser = (): boolean => !(window as any).invokeNative;
@@ -0,0 +1,28 @@
import { onMount, onDestroy } from 'svelte';
interface NuiMessage<T = unknown> {
action: string;
data: T;
}
/**
* A function that manage events listeners for receiving data from the client scripts
* @param action The specific `action` that should be listened for.
* @param handler The callback function that will handle data relayed by this function
*
* @example
* useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => {
* // whatever logic you want
* })
*
**/
export function useNuiEvent<T = unknown>(action: string, handler: (data: T) => void) {
const eventListener = (event: MessageEvent<NuiMessage<T>>) => {
const { action: eventAction, data } = event.data;
eventAction === action && handler(data);
};
onMount(() => window.addEventListener('message', eventListener));
onDestroy(() => window.removeEventListener('message', eventListener));
}
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
@@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
};
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,svelte}'],
theme: {
extend: {
fontFamily: {
main: 'Roboto',
},
colors: {
// Mantine dark colours
dark: {
50: '#C1C2C5',
100: '#A6A7AB',
200: '#909296',
300: '#5C5F66',
400: '#373A40',
500: '#2C2E33',
600: '#25262B',
700: '#1A1B1E',
800: '#141517',
900: '#101113',
},
},
},
},
plugins: [],
};
@@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node"
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
base: './',
server: {
port: 3000,
},
build: {
outDir: 'build',
},
});
File diff suppressed because it is too large Load Diff