create-rebe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/bin/create-rebe.js +310 -0
- package/package.json +40 -0
- package/template/.env.example +141 -0
- package/template/.gitattributes +16 -0
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +10 -0
- package/template/.prettierrc +12 -0
- package/template/LICENSE +21 -0
- package/template/README.md +150 -0
- package/template/SECURITY.md +133 -0
- package/template/app/hooks/register.hook.js +22 -0
- package/template/app/http/controllers/auth.controller.js +34 -0
- package/template/app/http/middlewares/auth.middleware.js +25 -0
- package/template/app/http/middlewares/register.middleware.js +18 -0
- package/template/app/http/validators/auth.validator.js +8 -0
- package/template/app/jobs/register.job.js +17 -0
- package/template/app/queue/register.queue.js +11 -0
- package/template/app/routes/api.route.js +18 -0
- package/template/app/routes/register.route.js +8 -0
- package/template/app/routes/web.route.js +7 -0
- package/template/app/socket/register.socket.js +15 -0
- package/template/config/app.config.js +12 -0
- package/template/config/bcrypt.config.js +7 -0
- package/template/config/cache.config.js +7 -0
- package/template/config/cors.config.js +37 -0
- package/template/config/cron.config.js +9 -0
- package/template/config/database.config.js +51 -0
- package/template/config/express.config.js +37 -0
- package/template/config/index.js +19 -0
- package/template/config/jwt.config.js +10 -0
- package/template/config/logger.config.js +10 -0
- package/template/config/mail.config.js +14 -0
- package/template/config/queue.config.js +11 -0
- package/template/config/redis.config.js +32 -0
- package/template/config/runtime.config.js +11 -0
- package/template/config/socket.config.js +15 -0
- package/template/config/storage.config.js +10 -0
- package/template/core/bootstrap.core.js +92 -0
- package/template/core/common/array.js +144 -0
- package/template/core/common/cache.js +100 -0
- package/template/core/common/collection.js +173 -0
- package/template/core/common/crypt.js +69 -0
- package/template/core/common/date.js +254 -0
- package/template/core/common/hash.js +61 -0
- package/template/core/common/object.js +155 -0
- package/template/core/common/path.js +80 -0
- package/template/core/common/storage.js +97 -0
- package/template/core/common/string.js +137 -0
- package/template/core/common/url.js +81 -0
- package/template/core/common.core.js +93 -0
- package/template/core/cron.core.js +141 -0
- package/template/core/database.core.js +113 -0
- package/template/core/error.core.js +83 -0
- package/template/core/express.core.js +161 -0
- package/template/core/hooks.core.js +47 -0
- package/template/core/jwt.core.js +81 -0
- package/template/core/logger.core.js +100 -0
- package/template/core/mailer.core.js +65 -0
- package/template/core/queue.core.js +226 -0
- package/template/core/redis.core.js +75 -0
- package/template/core/register.core.js +91 -0
- package/template/core/runtime.core.js +27 -0
- package/template/core/socket.core.js +93 -0
- package/template/core/validator.core.js +34 -0
- package/template/database/.gitkeep +0 -0
- package/template/database/models/post.model.js +26 -0
- package/template/database/models/user.model.js +30 -0
- package/template/docs/Bootstrap.md +50 -0
- package/template/docs/Common.md +48 -0
- package/template/docs/Cron.md +47 -0
- package/template/docs/Database.md +61 -0
- package/template/docs/Express.md +63 -0
- package/template/docs/Hooks.md +48 -0
- package/template/docs/Jwt.md +63 -0
- package/template/docs/Logger.md +60 -0
- package/template/docs/Mailer.md +50 -0
- package/template/docs/Queue.md +57 -0
- package/template/docs/README.md +32 -0
- package/template/docs/Redis.md +54 -0
- package/template/docs/Register.md +52 -0
- package/template/docs/Socket.md +55 -0
- package/template/docs/Validator.md +45 -0
- package/template/ecosystem.config.js +54 -0
- package/template/index.js +6 -0
- package/template/jest.config.js +27 -0
- package/template/jsconfig.json +37 -0
- package/template/logs/.gitkeep +0 -0
- package/template/nodemon.json +23 -0
- package/template/package-lock.json +7033 -0
- package/template/package.json +82 -0
- package/template/scripts/cli.js +258 -0
- package/template/storage/.gitkeep +0 -0
- package/template/tests/common.test.js +45 -0
- package/template/tests/crypt-hash-storage.test.js +44 -0
- package/template/tests/jwt.test.js +45 -0
- package/template/tests/register.test.js +55 -0
- package/template/tests/setup.js +11 -0
- package/template/tests/validator.test.js +65 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
app: require('@config/app.config'),
|
|
5
|
+
cors: require('@config/cors.config'),
|
|
6
|
+
socket: require('@config/socket.config'),
|
|
7
|
+
db: require('@config/database.config'),
|
|
8
|
+
jwt: require('@config/jwt.config'),
|
|
9
|
+
bcrypt: require('@config/bcrypt.config'),
|
|
10
|
+
storage: require('@config/storage.config'),
|
|
11
|
+
logger: require('@config/logger.config'),
|
|
12
|
+
runtime: require('@config/runtime.config'),
|
|
13
|
+
cache: require('@config/cache.config'),
|
|
14
|
+
redis: require('@config/redis.config'),
|
|
15
|
+
cron: require('@config/cron.config'),
|
|
16
|
+
queue: require('@config/queue.config'),
|
|
17
|
+
mail: require('@config/mail.config'),
|
|
18
|
+
express: require('@config/express.config'),
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
secret: Common.getEnv('JWT_SECRET', null),
|
|
7
|
+
expiresIn: Common.getEnv('JWT_EXPIRES_IN', '1d'),
|
|
8
|
+
refreshSecret: Common.getEnv('JWT_REFRESH_SECRET', null),
|
|
9
|
+
refreshExpiresIn: Common.getEnv('JWT_REFRESH_EXPIRES_IN', '7d'),
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
level: Common.getEnv('LOG_LEVEL', 'debug'),
|
|
7
|
+
file: Common.getEnv('LOG_FILE', 'logs'),
|
|
8
|
+
maxSize: Common.getEnv('LOG_MAX_SIZE', '20m'),
|
|
9
|
+
maxFiles: Common.getEnv('LOG_MAX_FILES', '14d'),
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
enabled: Common.getEnvBool('MAIL_ENABLED', false),
|
|
7
|
+
host: Common.getEnv('MAIL_HOST', null),
|
|
8
|
+
port: Common.getEnvInt('MAIL_PORT', 587),
|
|
9
|
+
secure: Common.getEnvBool('MAIL_SECURE', false),
|
|
10
|
+
username: Common.getEnv('MAIL_USERNAME', null),
|
|
11
|
+
password: Common.getEnv('MAIL_PASSWORD', null),
|
|
12
|
+
fromAddress: Common.getEnv('MAIL_FROM_ADDRESS', null),
|
|
13
|
+
fromName: Common.getEnv('MAIL_FROM_NAME', null),
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
enabled: Common.getEnvBool('QUEUE_ENABLED', true),
|
|
7
|
+
concurrency: Common.getEnvInt('QUEUE_CONCURRENCY', 5),
|
|
8
|
+
maxRetries: Common.getEnvInt('QUEUE_MAX_RETRIES', 3),
|
|
9
|
+
retryDelay: Common.getEnvInt('QUEUE_RETRY_DELAY', 1000),
|
|
10
|
+
persist: Common.getEnvBool('QUEUE_PERSIST', true),
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
// Konfigurasi Redis (standalone). `redis.core` membuat koneksi hanya bila
|
|
6
|
+
// `enabled=true`. Pemakaian Redis oleh fitur lain bersifat OPSIONAL dan diatur
|
|
7
|
+
// lewat flag `use.*` — masing-masing core (cache/queue/socket/session) memeriksa
|
|
8
|
+
// `config.redis.enabled && config.redis.use.<fitur>` sebelum memakai Redis,
|
|
9
|
+
// dan jatuh ke implementasi in-memory/default bila false.
|
|
10
|
+
module.exports = {
|
|
11
|
+
enabled: Common.getEnvBool('REDIS_ENABLED', false),
|
|
12
|
+
|
|
13
|
+
host: Common.getEnv('REDIS_HOST', '127.0.0.1'),
|
|
14
|
+
port: Common.getEnvInt('REDIS_PORT', 6379),
|
|
15
|
+
username: Common.getEnv('REDIS_USERNAME', null),
|
|
16
|
+
password: Common.getEnv('REDIS_PASSWORD', null),
|
|
17
|
+
db: Common.getEnvInt('REDIS_DB', 0),
|
|
18
|
+
keyPrefix: Common.getEnv('REDIS_KEY_PREFIX', ''),
|
|
19
|
+
tls: Common.getEnvBool('REDIS_TLS', false),
|
|
20
|
+
|
|
21
|
+
// Reconnect/retry (dipakai ioredis)
|
|
22
|
+
maxRetriesPerRequest: Common.getEnvInt('REDIS_MAX_RETRIES', 3),
|
|
23
|
+
connectTimeout: Common.getEnvInt('REDIS_CONNECT_TIMEOUT', 10000),
|
|
24
|
+
|
|
25
|
+
// Toggle pemakaian Redis per fitur (hanya berlaku bila enabled=true)
|
|
26
|
+
use: {
|
|
27
|
+
cache: Common.getEnvBool('REDIS_USE_CACHE', false),
|
|
28
|
+
queue: Common.getEnvBool('REDIS_USE_QUEUE', false),
|
|
29
|
+
socket: Common.getEnvBool('REDIS_USE_SOCKET', false),
|
|
30
|
+
session: Common.getEnvBool('REDIS_USE_SESSION', false),
|
|
31
|
+
},
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
maxListeners: Common.getEnvInt('RUNTIME_MAX_LISTENERS', 50),
|
|
7
|
+
stackTraceLimit: Common.getEnvInt('RUNTIME_STACK_TRACE_LIMIT', 50),
|
|
8
|
+
uvThreadpoolSize: Common.getEnvInt('RUNTIME_UV_THREADPOOL_SIZE', 4),
|
|
9
|
+
bigintJson: Common.getEnvBool('RUNTIME_BIGINT_JSON', true),
|
|
10
|
+
deprecationWarnings: Common.getEnvBool('RUNTIME_DEPRECATION_WARNINGS', true),
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
enabled: Common.getEnvBool('SOCKET_ENABLED', true),
|
|
7
|
+
path: Common.getEnv('SOCKET_PATH', '/socket.io/'),
|
|
8
|
+
serveClient: Common.getEnvBool('SOCKET_SERVE_CLIENT', false),
|
|
9
|
+
connectTimeout: Common.getEnvInt('SOCKET_CONNECT_TIMEOUT', 45000),
|
|
10
|
+
pingInterval: Common.getEnvInt('SOCKET_PING_INTERVAL', 25000),
|
|
11
|
+
pingTimeout: Common.getEnvInt('SOCKET_PING_TIMEOUT', 20000),
|
|
12
|
+
transports: Common.getEnvArray('SOCKET_TRANSPORTS', ['polling', 'websocket']),
|
|
13
|
+
allowUpgrades: Common.getEnvBool('SOCKET_ALLOW_UPGRADES', true),
|
|
14
|
+
maxHttpBufferSize: Common.getEnvInt('SOCKET_MAX_HTTP_BUFFER_SIZE', 1e6),
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Common = require('@core/common.core')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
root: Common.getEnv('STORAGE_ROOT', 'storage'),
|
|
7
|
+
uploadPath: Common.getEnv('STORAGE_UPLOAD_PATH', 'storage/uploads'),
|
|
8
|
+
maxSizeMb: Common.getEnvInt('STORAGE_UPLOAD_MAX_SIZE_MB', 50),
|
|
9
|
+
allowedTypes: Common.getEnvArray('STORAGE_UPLOAD_ALLOWED_TYPES', ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']),
|
|
10
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const config = require('@config/index')
|
|
4
|
+
const logger = require('@core/logger.core')
|
|
5
|
+
const ErrorHandler = require('@core/error.core')
|
|
6
|
+
const Hooks = require('@core/hooks.core')
|
|
7
|
+
const Express = require('@core/express.core')
|
|
8
|
+
const Socket = require('@core/socket.core')
|
|
9
|
+
const Cron = require('@core/cron.core')
|
|
10
|
+
const Queue = require('@core/queue.core')
|
|
11
|
+
|
|
12
|
+
// Orchestrates the boot sequence in a deterministic order and wires graceful
|
|
13
|
+
// shutdown. Optional subsystems (Redis, Database) are required lazily so a project
|
|
14
|
+
// that never enables them does not pay their load cost.
|
|
15
|
+
class Bootstrap {
|
|
16
|
+
static _shuttingDown = false
|
|
17
|
+
|
|
18
|
+
static async run() {
|
|
19
|
+
ErrorHandler.setExceptionHandler()
|
|
20
|
+
ErrorHandler.setRejectionHandler()
|
|
21
|
+
|
|
22
|
+
Bootstrap._banner()
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await Hooks.before({ config })
|
|
26
|
+
|
|
27
|
+
if (config.redis.enabled) {
|
|
28
|
+
await require('@core/redis.core').connect()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (config.db.enabled) {
|
|
32
|
+
await require('@core/database.core').connect()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { server } = await Express.create()
|
|
36
|
+
await Socket.attach(server)
|
|
37
|
+
await Cron.start()
|
|
38
|
+
await Queue.start()
|
|
39
|
+
await Express.listen()
|
|
40
|
+
|
|
41
|
+
await Hooks.after({ config })
|
|
42
|
+
|
|
43
|
+
logger.info(`${config.app.name} ready`)
|
|
44
|
+
Bootstrap._registerShutdown()
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logger.error('Bootstrap failed', err)
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static _banner() {
|
|
52
|
+
logger.info(`Starting ${config.app.name}`)
|
|
53
|
+
logger.debug(`env=${config.app.env} tz=${config.app.timezone} port=${config.app.port}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static _registerShutdown() {
|
|
57
|
+
const shutdown = async (signal) => {
|
|
58
|
+
if (Bootstrap._shuttingDown) return
|
|
59
|
+
Bootstrap._shuttingDown = true
|
|
60
|
+
logger.info(`Received ${signal}, shutting down`)
|
|
61
|
+
|
|
62
|
+
await Bootstrap._safe('hooks.shutdown', () => Hooks.shutdown({ config }))
|
|
63
|
+
await Bootstrap._safe('cron.stop', () => Cron.stop())
|
|
64
|
+
await Bootstrap._safe('queue.stop', () => Queue.stop())
|
|
65
|
+
await Bootstrap._safe('socket.close', () => Socket.close())
|
|
66
|
+
await Bootstrap._safe('express.close', () => Express.close())
|
|
67
|
+
|
|
68
|
+
if (config.db.enabled) {
|
|
69
|
+
await Bootstrap._safe('database.disconnect', () => require('@core/database.core').disconnect())
|
|
70
|
+
}
|
|
71
|
+
if (config.redis.enabled) {
|
|
72
|
+
await Bootstrap._safe('redis.disconnect', () => require('@core/redis.core').disconnect())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
logger.info('Shutdown complete')
|
|
76
|
+
process.exit(0)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
80
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async _safe(label, fn) {
|
|
84
|
+
try {
|
|
85
|
+
await fn()
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.error(`Shutdown step "${label}" failed`, err)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = Bootstrap
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Array helpers — small, predictable utilities for everyday list work.
|
|
4
|
+
class Arr {
|
|
5
|
+
// Wrap any value into an array. null/undefined become an empty array.
|
|
6
|
+
static wrap(value) {
|
|
7
|
+
if (value === null || value === undefined) return []
|
|
8
|
+
return Array.isArray(value) ? value : [value]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Return the first element, or the value matching a predicate.
|
|
12
|
+
static first(arr, predicate = null, fallback = null) {
|
|
13
|
+
if (!Array.isArray(arr)) return fallback
|
|
14
|
+
if (typeof predicate === 'function') {
|
|
15
|
+
const found = arr.find(predicate)
|
|
16
|
+
return found === undefined ? fallback : found
|
|
17
|
+
}
|
|
18
|
+
return arr.length ? arr[0] : fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Return the last element, or the value matching a predicate.
|
|
22
|
+
static last(arr, predicate = null, fallback = null) {
|
|
23
|
+
if (!Array.isArray(arr)) return fallback
|
|
24
|
+
if (typeof predicate === 'function') {
|
|
25
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
26
|
+
if (predicate(arr[i], i)) return arr[i]
|
|
27
|
+
}
|
|
28
|
+
return fallback
|
|
29
|
+
}
|
|
30
|
+
return arr.length ? arr[arr.length - 1] : fallback
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Remove duplicate values. With a key/selector, dedupe objects by that key.
|
|
34
|
+
static unique(arr, key = null) {
|
|
35
|
+
if (!Array.isArray(arr)) return []
|
|
36
|
+
if (!key) return [...new Set(arr)]
|
|
37
|
+
const selector = typeof key === 'function' ? key : (item) => item[key]
|
|
38
|
+
const seen = new Set()
|
|
39
|
+
return arr.filter((item) => {
|
|
40
|
+
const id = selector(item)
|
|
41
|
+
if (seen.has(id)) return false
|
|
42
|
+
seen.add(id)
|
|
43
|
+
return true
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Flatten nested arrays up to the given depth.
|
|
48
|
+
static flatten(arr, depth = Infinity) {
|
|
49
|
+
return Array.isArray(arr) ? arr.flat(depth) : []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Split an array into chunks of the given size.
|
|
53
|
+
static chunk(arr, size = 1) {
|
|
54
|
+
if (!Array.isArray(arr) || size < 1) return []
|
|
55
|
+
const out = []
|
|
56
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
57
|
+
out.push(arr.slice(i, i + size))
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Group items into an object keyed by the result of a selector.
|
|
63
|
+
static groupBy(arr, key) {
|
|
64
|
+
const selector = typeof key === 'function' ? key : (item) => item[key]
|
|
65
|
+
return (arr || []).reduce((acc, item) => {
|
|
66
|
+
const group = selector(item)
|
|
67
|
+
;(acc[group] = acc[group] || []).push(item)
|
|
68
|
+
return acc
|
|
69
|
+
}, {})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Turn an array of objects into a lookup keyed by a property.
|
|
73
|
+
static keyBy(arr, key) {
|
|
74
|
+
const selector = typeof key === 'function' ? key : (item) => item[key]
|
|
75
|
+
return (arr || []).reduce((acc, item) => {
|
|
76
|
+
acc[selector(item)] = item
|
|
77
|
+
return acc
|
|
78
|
+
}, {})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pluck a single property out of each object.
|
|
82
|
+
static pluck(arr, key) {
|
|
83
|
+
return (arr || []).map((item) => item?.[key])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sum a numeric array, optionally via a selector.
|
|
87
|
+
static sum(arr, key = null) {
|
|
88
|
+
const selector = key ? (typeof key === 'function' ? key : (i) => i[key]) : (i) => i
|
|
89
|
+
return (arr || []).reduce((total, item) => total + (Number(selector(item)) || 0), 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Average of a numeric array, optionally via a selector.
|
|
93
|
+
static avg(arr, key = null) {
|
|
94
|
+
if (!arr || !arr.length) return 0
|
|
95
|
+
return Arr.sum(arr, key) / arr.length
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Shuffle a copy of the array (Fisher–Yates).
|
|
99
|
+
static shuffle(arr) {
|
|
100
|
+
const copy = [...(arr || [])]
|
|
101
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
102
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
103
|
+
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
|
104
|
+
}
|
|
105
|
+
return copy
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Pick one or more random elements.
|
|
109
|
+
static random(arr, count = 1) {
|
|
110
|
+
const shuffled = Arr.shuffle(arr)
|
|
111
|
+
return count === 1 ? shuffled[0] : shuffled.slice(0, count)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Everything in `a` that is not in `b`.
|
|
115
|
+
static difference(a, b) {
|
|
116
|
+
const set = new Set(b || [])
|
|
117
|
+
return (a || []).filter((item) => !set.has(item))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Values present in both arrays.
|
|
121
|
+
static intersect(a, b) {
|
|
122
|
+
const set = new Set(b || [])
|
|
123
|
+
return (a || []).filter((item) => set.has(item))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strip out null/undefined/false/empty-string entries.
|
|
127
|
+
static compact(arr) {
|
|
128
|
+
return (arr || []).filter((item) => item !== null && item !== undefined && item !== false && item !== '')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A range of numbers, inclusive of both ends.
|
|
132
|
+
static range(start, end, step = 1) {
|
|
133
|
+
const out = []
|
|
134
|
+
if (step === 0) return out
|
|
135
|
+
if (start <= end) {
|
|
136
|
+
for (let i = start; i <= end; i += step) out.push(i)
|
|
137
|
+
} else {
|
|
138
|
+
for (let i = start; i >= end; i -= Math.abs(step)) out.push(i)
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = Arr
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Process-local key/value cache with per-entry TTL. Suitable for hot data that
|
|
4
|
+
// can be safely recomputed; swap in a Redis driver later if you outgrow it.
|
|
5
|
+
const store = new Map()
|
|
6
|
+
|
|
7
|
+
// Lazily-started sweeper that drops expired keys so the Map does not grow.
|
|
8
|
+
let sweeper = null
|
|
9
|
+
|
|
10
|
+
function startSweeper() {
|
|
11
|
+
if (sweeper) return
|
|
12
|
+
sweeper = setInterval(() => {
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
for (const [key, entry] of store) {
|
|
15
|
+
if (entry.expires !== 0 && entry.expires <= now) store.delete(key)
|
|
16
|
+
}
|
|
17
|
+
}, 60 * 1000)
|
|
18
|
+
// Do not keep the event loop alive solely for cache cleanup.
|
|
19
|
+
if (sweeper.unref) sweeper.unref()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveTtl(ttlSeconds) {
|
|
23
|
+
if (ttlSeconds === null || ttlSeconds === undefined) {
|
|
24
|
+
const config = require('@config/index')
|
|
25
|
+
ttlSeconds = config.cache.ttl
|
|
26
|
+
}
|
|
27
|
+
return ttlSeconds === 0 ? 0 : Date.now() + ttlSeconds * 1000
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class Cache {
|
|
31
|
+
// Store a value for ttlSeconds (0 = never expires; null = config default).
|
|
32
|
+
static set(key, value, ttlSeconds = null) {
|
|
33
|
+
startSweeper()
|
|
34
|
+
store.set(key, { value, expires: resolveTtl(ttlSeconds) })
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Read a value, or fallback when missing/expired.
|
|
39
|
+
static get(key, fallback = null) {
|
|
40
|
+
const entry = store.get(key)
|
|
41
|
+
if (!entry) return fallback
|
|
42
|
+
if (entry.expires !== 0 && entry.expires <= Date.now()) {
|
|
43
|
+
store.delete(key)
|
|
44
|
+
return fallback
|
|
45
|
+
}
|
|
46
|
+
return entry.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static has(key) {
|
|
50
|
+
return Cache.get(key, Symbol.for('miss')) !== Symbol.for('miss')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static forget(key) {
|
|
54
|
+
return store.delete(key)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static flush() {
|
|
58
|
+
store.clear()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Return the cached value, or compute it via the resolver and cache it.
|
|
62
|
+
static async remember(key, ttlSeconds, resolver) {
|
|
63
|
+
const cached = Cache.get(key, Symbol.for('miss'))
|
|
64
|
+
if (cached !== Symbol.for('miss')) return cached
|
|
65
|
+
const value = await resolver()
|
|
66
|
+
Cache.set(key, value, ttlSeconds)
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// remember() with no expiry.
|
|
71
|
+
static async rememberForever(key, resolver) {
|
|
72
|
+
return Cache.remember(key, 0, resolver)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get and delete in one step.
|
|
76
|
+
static pull(key, fallback = null) {
|
|
77
|
+
const value = Cache.get(key, fallback)
|
|
78
|
+
store.delete(key)
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Atomically increment/decrement a numeric entry.
|
|
83
|
+
static increment(key, amount = 1) {
|
|
84
|
+
const current = Number(Cache.get(key, 0)) || 0
|
|
85
|
+
const next = current + amount
|
|
86
|
+
const entry = store.get(key)
|
|
87
|
+
Cache.set(key, next, entry ? null : 0)
|
|
88
|
+
return next
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static decrement(key, amount = 1) {
|
|
92
|
+
return Cache.increment(key, -amount)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static keys() {
|
|
96
|
+
return [...store.keys()]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = Cache
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// A fluent, chainable wrapper over an array — inspired by Laravel collections.
|
|
4
|
+
// Every transforming method returns a new Collection so chains stay immutable.
|
|
5
|
+
class Collection {
|
|
6
|
+
constructor(items = []) {
|
|
7
|
+
this.items = Array.isArray(items) ? [...items] : Object.values(items || {})
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static make(items = []) {
|
|
11
|
+
return new Collection(items)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
all() {
|
|
15
|
+
return this.items
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
count() {
|
|
19
|
+
return this.items.length
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
isEmpty() {
|
|
23
|
+
return this.items.length === 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isNotEmpty() {
|
|
27
|
+
return this.items.length > 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
map(fn) {
|
|
31
|
+
return new Collection(this.items.map(fn))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
filter(fn = (v) => Boolean(v)) {
|
|
35
|
+
return new Collection(this.items.filter(fn))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
reject(fn) {
|
|
39
|
+
return new Collection(this.items.filter((v, i) => !fn(v, i)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
each(fn) {
|
|
43
|
+
this.items.forEach(fn)
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
reduce(fn, initial) {
|
|
48
|
+
return this.items.reduce(fn, initial)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Filter by a field/value pair: where('status', 'active').
|
|
52
|
+
where(key, value) {
|
|
53
|
+
return new Collection(this.items.filter((item) => item?.[key] === value))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
whereIn(key, values) {
|
|
57
|
+
const set = new Set(values)
|
|
58
|
+
return new Collection(this.items.filter((item) => set.has(item?.[key])))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
first(fn = null, fallback = null) {
|
|
62
|
+
if (fn) {
|
|
63
|
+
const found = this.items.find(fn)
|
|
64
|
+
return found === undefined ? fallback : found
|
|
65
|
+
}
|
|
66
|
+
return this.items.length ? this.items[0] : fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
last(fallback = null) {
|
|
70
|
+
return this.items.length ? this.items[this.items.length - 1] : fallback
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pluck(key) {
|
|
74
|
+
return new Collection(this.items.map((item) => item?.[key]))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
unique(key = null) {
|
|
78
|
+
if (!key) return new Collection([...new Set(this.items)])
|
|
79
|
+
const seen = new Set()
|
|
80
|
+
return new Collection(
|
|
81
|
+
this.items.filter((item) => {
|
|
82
|
+
const id = item?.[key]
|
|
83
|
+
if (seen.has(id)) return false
|
|
84
|
+
seen.add(id)
|
|
85
|
+
return true
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
sortBy(key, direction = 'asc') {
|
|
91
|
+
const selector = typeof key === 'function' ? key : (item) => item?.[key]
|
|
92
|
+
const sorted = [...this.items].sort((a, b) => {
|
|
93
|
+
const va = selector(a)
|
|
94
|
+
const vb = selector(b)
|
|
95
|
+
if (va < vb) return direction === 'asc' ? -1 : 1
|
|
96
|
+
if (va > vb) return direction === 'asc' ? 1 : -1
|
|
97
|
+
return 0
|
|
98
|
+
})
|
|
99
|
+
return new Collection(sorted)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
groupBy(key) {
|
|
103
|
+
const selector = typeof key === 'function' ? key : (item) => item?.[key]
|
|
104
|
+
const groups = {}
|
|
105
|
+
for (const item of this.items) {
|
|
106
|
+
const g = selector(item)
|
|
107
|
+
;(groups[g] = groups[g] || []).push(item)
|
|
108
|
+
}
|
|
109
|
+
return groups
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
take(count) {
|
|
113
|
+
return new Collection(count < 0 ? this.items.slice(count) : this.items.slice(0, count))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
skip(count) {
|
|
117
|
+
return new Collection(this.items.slice(count))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
chunk(size) {
|
|
121
|
+
const out = []
|
|
122
|
+
for (let i = 0; i < this.items.length; i += size) {
|
|
123
|
+
out.push(new Collection(this.items.slice(i, i + size)))
|
|
124
|
+
}
|
|
125
|
+
return new Collection(out)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sum(key = null) {
|
|
129
|
+
const selector = key ? (item) => item?.[key] : (item) => item
|
|
130
|
+
return this.items.reduce((total, item) => total + (Number(selector(item)) || 0), 0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
avg(key = null) {
|
|
134
|
+
return this.items.length ? this.sum(key) / this.items.length : 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
min(key = null) {
|
|
138
|
+
const selector = key ? (item) => item?.[key] : (item) => item
|
|
139
|
+
return this.items.length ? Math.min(...this.items.map(selector)) : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
max(key = null) {
|
|
143
|
+
const selector = key ? (item) => item?.[key] : (item) => item
|
|
144
|
+
return this.items.length ? Math.max(...this.items.map(selector)) : null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
contains(value) {
|
|
148
|
+
return this.items.includes(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
reverse() {
|
|
152
|
+
return new Collection([...this.items].reverse())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
values() {
|
|
156
|
+
return new Collection([...this.items])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
toArray() {
|
|
160
|
+
return this.items
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
toJson() {
|
|
164
|
+
return JSON.stringify(this.items)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Make the collection itself iterable (for...of, spread).
|
|
168
|
+
[Symbol.iterator]() {
|
|
169
|
+
return this.items[Symbol.iterator]()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = Collection
|