create-rebe 1.0.0 → 3.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/package.json +1 -1
- package/template/.env.example +4 -0
- package/template/README.md +63 -8
- package/template/SECURITY.md +39 -0
- package/template/app/routes/api.route.js +1 -1
- package/template/app/routes/register.route.js +1 -1
- package/template/app/routes/web.route.js +1 -1
- package/template/app/socket/register.socket.js +0 -2
- package/template/config/express.config.js +8 -0
- package/template/core/common/string.js +1 -1
- package/template/core/cron.core.js +1 -0
- package/template/core/database.core.js +30 -17
- package/template/core/error.core.js +8 -4
- package/template/core/express.core.js +16 -4
- package/template/core/hooks.core.js +10 -7
- package/template/core/migrator.core.js +201 -0
- package/template/core/modules.core.js +167 -0
- package/template/core/queue.core.js +1 -0
- package/template/core/routing.core.d.ts +273 -0
- package/template/core/routing.core.js +666 -0
- package/template/core/seeder.core.js +105 -0
- package/template/core/socket.core.js +1 -0
- package/template/database/migrations/.gitkeep +0 -0
- package/template/database/seeders/.gitkeep +0 -0
- package/template/docs/Database.md +14 -8
- package/template/docs/Express.md +5 -2
- package/template/docs/Make.md +46 -0
- package/template/docs/Migration.md +56 -0
- package/template/docs/Modules.md +96 -0
- package/template/docs/README.md +5 -0
- package/template/docs/Routing.md +116 -0
- package/template/docs/Seeder.md +54 -0
- package/template/eslint.config.js +52 -0
- package/template/package-lock.json +1068 -70
- package/template/package.json +15 -8
- package/template/scripts/cli/args.js +39 -0
- package/template/scripts/cli/bootstrap.js +16 -0
- package/template/scripts/cli/db.js +79 -0
- package/template/scripts/cli/help.js +58 -0
- package/template/scripts/cli/keys.js +100 -0
- package/template/scripts/cli/log.js +58 -0
- package/template/scripts/cli/make.js +249 -0
- package/template/scripts/cli/names.js +51 -0
- package/template/scripts/cli/templates.js +358 -0
- package/template/scripts/cli.js +75 -234
- package/template/tests/http.test.js +99 -0
package/package.json
CHANGED
package/template/.env.example
CHANGED
|
@@ -19,6 +19,10 @@ EXPRESS_TRUST_PROXY=0
|
|
|
19
19
|
EXPRESS_RATE_LIMIT_ENABLED=true
|
|
20
20
|
EXPRESS_RATE_LIMIT_WINDOW_MS=900000
|
|
21
21
|
EXPRESS_RATE_LIMIT_MAX=100
|
|
22
|
+
# HTTP server timeouts (ms; 0 = Node default). Guard against slow-request/slowloris DoS.
|
|
23
|
+
EXPRESS_REQUEST_TIMEOUT_MS=30000
|
|
24
|
+
EXPRESS_HEADERS_TIMEOUT_MS=15000
|
|
25
|
+
EXPRESS_KEEPALIVE_TIMEOUT_MS=5000
|
|
22
26
|
|
|
23
27
|
# ========== Database ==========
|
|
24
28
|
DB_ENABLED=false
|
package/template/README.md
CHANGED
|
@@ -19,12 +19,13 @@ with `APP_NAME` set to the project name and fresh `APP_KEY` / JWT secrets.
|
|
|
19
19
|
|
|
20
20
|
## What's inside
|
|
21
21
|
|
|
22
|
-
- **HTTP** — Express 5 + Laravel-style routing, helmet,
|
|
23
|
-
limiting, body limits, multipart uploads.
|
|
22
|
+
- **HTTP** — Express 5 + a built-in Laravel-style router (`@core/routing.core`), helmet,
|
|
23
|
+
CORS, compression, rate limiting, body limits, multipart uploads.
|
|
24
24
|
- **Realtime** — Socket.IO on the same port, optional Redis adapter.
|
|
25
25
|
- **Background** — node-cron scheduler and an in-process job queue (optional DB
|
|
26
26
|
persistence).
|
|
27
|
-
- **Data** — Sequelize (MySQL/MariaDB/Postgres/MSSQL/SQLite), enabled on demand
|
|
27
|
+
- **Data** — Sequelize (MySQL/MariaDB/Postgres/MSSQL/SQLite), enabled on demand, with
|
|
28
|
+
a built-in migration runner, seeders, and a generator CLI.
|
|
28
29
|
- **Auth** — JWT (access + refresh, HS256-pinned), bcrypt.
|
|
29
30
|
- **Ops** — Winston logging with daily rotation, lifecycle hooks, graceful shutdown,
|
|
30
31
|
PM2 config.
|
|
@@ -34,12 +35,14 @@ with `APP_NAME` set to the project name and fresh `APP_KEY` / JWT secrets.
|
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
37
|
npm install
|
|
37
|
-
npm run cli -- setup # copy .env,
|
|
38
|
+
npm run cli -- setup # copy .env, install deps, generate keys
|
|
38
39
|
npm run dev # nodemon, development
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
`setup` copies `.env.example` to `.env
|
|
42
|
-
`
|
|
42
|
+
`setup` copies `.env.example` to `.env`, installs the pinned dependencies, and
|
|
43
|
+
generates `APP_KEY`, `JWT_SECRET`, and `JWT_REFRESH_SECRET`. Pass `--upgrade` to also
|
|
44
|
+
bump dependencies via `npm-check-updates` (off by default, so a clean install stays on
|
|
45
|
+
the audited versions). Generate keys individually with `npm run cli -- key:generate`
|
|
43
46
|
and `npm run cli -- key:jwt [--refresh]`.
|
|
44
47
|
|
|
45
48
|
## Development vs production
|
|
@@ -98,12 +101,16 @@ loader — Inversion of Control keeps the arrow pointing one way.
|
|
|
98
101
|
│ ├── queue/register.queue.js # (Queue) => {}
|
|
99
102
|
│ ├── socket/register.socket.js # (io, Socket) => {}
|
|
100
103
|
│ └── hooks/register.hook.js # { before, after, shutdown }
|
|
101
|
-
├── database/ #
|
|
104
|
+
├── database/ # -> @database
|
|
105
|
+
│ ├── models/ # Sequelize models
|
|
106
|
+
│ ├── migrations/ # up/down migrations (tracked in _migrations)
|
|
107
|
+
│ └── seeders/ # data seeders
|
|
108
|
+
├── modules/ # Optional feature modules (entry: <name>.module.js)
|
|
102
109
|
├── storage/ # Local file storage / uploads -> @storage
|
|
103
110
|
├── tests/ # Jest test suite
|
|
104
111
|
├── create-rebe/ # The `npm create rebe` scaffolder package
|
|
105
112
|
├── docs/ # Per-core API reference
|
|
106
|
-
├── scripts/cli.js # Project CLI (npm run cli)
|
|
113
|
+
├── scripts/cli.js # Project CLI dispatcher (npm run cli) + scripts/cli/
|
|
107
114
|
├── index.js # Entry: dotenv -> module-alias -> runtime -> Bootstrap.run()
|
|
108
115
|
└── ecosystem.config.js # PM2 (fork, single instance)
|
|
109
116
|
```
|
|
@@ -145,6 +152,54 @@ npm run format # prettier --write .
|
|
|
145
152
|
npm run cli -- help
|
|
146
153
|
```
|
|
147
154
|
|
|
155
|
+
## CLI
|
|
156
|
+
|
|
157
|
+
The project CLI (`npm run cli -- <command>`) scaffolds code and manages the database.
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
# Project / keys
|
|
161
|
+
npm run cli -- setup [--upgrade] # .env + install + keys (--upgrade runs npm-check-updates)
|
|
162
|
+
npm run cli -- key:generate # APP_KEY
|
|
163
|
+
npm run cli -- key:jwt [--refresh] # JWT_SECRET / JWT_REFRESH_SECRET
|
|
164
|
+
|
|
165
|
+
# Scaffolding (every make: supports --module=<name>; see docs/Make.md)
|
|
166
|
+
npm run cli -- make:model <Name> # model + matching create-table migration
|
|
167
|
+
npm run cli -- make:migration <name> # blank up/down migration
|
|
168
|
+
npm run cli -- make:seed <Name> # seeder
|
|
169
|
+
npm run cli -- make:controller <Name> [--resource] [--api]
|
|
170
|
+
npm run cli -- make:middleware <Name> # handle() middleware class
|
|
171
|
+
npm run cli -- make:validator <Name> # zod schemas
|
|
172
|
+
npm run cli -- make:route <name> # route group file
|
|
173
|
+
npm run cli -- make:job|queue|socket <Name> # background / realtime modules
|
|
174
|
+
npm run cli -- make:hook # { before, after, shutdown }
|
|
175
|
+
npm run cli -- make:resource <Name> [--api] # controller + model + migration + validator + route
|
|
176
|
+
npm run cli -- make:module <name> # full mini-app module (http/, routes, jobs, queue, socket, hooks, data)
|
|
177
|
+
|
|
178
|
+
# Database
|
|
179
|
+
npm run cli -- db:migrate # run pending migrations (alias: migrate)
|
|
180
|
+
npm run cli -- db:rollback [--step=N] # roll back the last batch (alias: rollback)
|
|
181
|
+
npm run cli -- db:reset [--seed] # drop all tables → migrate (alias: reset)
|
|
182
|
+
npm run cli -- db:refresh [--seed] # rollback all via down() → migrate
|
|
183
|
+
npm run cli -- db:fresh [--seed] # alias of db:reset
|
|
184
|
+
npm run cli -- db:status # applied vs pending migrations
|
|
185
|
+
npm run cli -- db:wipe # drop all tables (no migrate)
|
|
186
|
+
npm run cli -- db:seed [--all] [--class=Name] # run default/named seeder (alias: seed)
|
|
187
|
+
npm run cli -- db:seed-all # run every seeder (alias: seed-all)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Migrations are tracked in a `_migrations` table and ordered globally by their
|
|
191
|
+
`YYYYMMDDHHmmss_` filename prefix. Once migrations own the schema, keep `DB_FORCE` /
|
|
192
|
+
`DB_ALTER` **off** in production. See [docs/Migration.md](./docs/Migration.md) and
|
|
193
|
+
[docs/Seeder.md](./docs/Seeder.md).
|
|
194
|
+
|
|
195
|
+
## Modular structure (optional)
|
|
196
|
+
|
|
197
|
+
Group a feature's models, routes, migrations, seeders, jobs, queue, and socket
|
|
198
|
+
handlers under `modules/<name>/` behind a single entry file
|
|
199
|
+
(`<name>.module.js`). It's additive — the flat layout keeps working, and a project
|
|
200
|
+
with no `modules/` directory is unaffected. Scaffold one with
|
|
201
|
+
`npm run cli -- make:module <name>`; see [docs/Modules.md](./docs/Modules.md).
|
|
202
|
+
|
|
148
203
|
## License
|
|
149
204
|
|
|
150
205
|
MIT
|
package/template/SECURITY.md
CHANGED
|
@@ -21,6 +21,10 @@ that you should review against your threat model before deploying.
|
|
|
21
21
|
| F-08 | Info | CWE-352 | No CSRF protection | N/A for bearer API |
|
|
22
22
|
| F-09 | Low | CWE-798 | Demo credentials in sample code; secrets in local `.env` | Guidance |
|
|
23
23
|
| F-10 | Info | CWE-770 | Rate limit / trust-proxy defaults | Guidance |
|
|
24
|
+
| F-11 | High | CWE-400 | `ws` / `form-data` transitive DoS & CRLF advisories (socket.io, axios) | Fixed (2.0.0) |
|
|
25
|
+
| F-12 | Moderate | CWE-1395 | `js-yaml` DoS in the `jest` dev-dependency tree | Accepted (dev-only) |
|
|
26
|
+
| F-13 | Low | CWE-400 | No HTTP server timeouts — slow-request / slowloris DoS surface | Fixed (3.0.0) |
|
|
27
|
+
| F-14 | Info | CWE-1059 | Error JSON envelope diverged from the documented `{ status, code, … }` shape | Fixed (3.0.0) |
|
|
24
28
|
|
|
25
29
|
## Findings
|
|
26
30
|
|
|
@@ -104,6 +108,41 @@ see the true client IP; leaving it at `0` while behind a proxy lets clients shar
|
|
|
104
108
|
bucket. Do **not** set an overly permissive trust-proxy value when directly exposed —
|
|
105
109
|
it would let clients spoof `X-Forwarded-For`.
|
|
106
110
|
|
|
111
|
+
### F-11 — Transitive `ws` / `form-data` advisories (Fixed in 2.0.0)
|
|
112
|
+
|
|
113
|
+
`npm audit` reported four high-severity transitive advisories: memory-exhaustion DoS in
|
|
114
|
+
`ws` (via `socket.io` → `engine.io` / `socket.io-adapter`) and CRLF injection in
|
|
115
|
+
`form-data` (via `axios`). All were resolved by a non-breaking `npm audit fix`
|
|
116
|
+
(`ws 8.20.1 → 8.21.0`, `engine.io`, `socket.io-adapter`, `form-data 4.0.5 → 4.0.6`); the
|
|
117
|
+
direct `socket.io` / `axios` versions are unchanged. Production dependency tree is clean
|
|
118
|
+
of high-severity advisories.
|
|
119
|
+
|
|
120
|
+
### F-12 — `js-yaml` DoS in the jest dev tree (Accepted / dev-only)
|
|
121
|
+
|
|
122
|
+
The remaining moderate advisories all originate from `jest`'s transitive
|
|
123
|
+
`@istanbuljs/load-nyc-config → js-yaml` chain (quadratic-complexity DoS in YAML merge-key
|
|
124
|
+
parsing). `jest` is already on its latest major and no non-breaking fix is published, so
|
|
125
|
+
these are accepted: they are **devDependencies** only, exercised against trusted local
|
|
126
|
+
test-config files, with no production runtime exposure. Re-run `npm audit` after jest
|
|
127
|
+
updates.
|
|
128
|
+
|
|
129
|
+
### F-13 — Missing HTTP server timeouts (Fixed in 3.0.0)
|
|
130
|
+
|
|
131
|
+
`express.core` created the `http.Server` without explicit socket timeouts, leaving the
|
|
132
|
+
slow-request / slowloris surface to Node defaults (no per-request cap; long header
|
|
133
|
+
phase). The server now applies configurable `requestTimeout` (30s), `headersTimeout`
|
|
134
|
+
(15s), and `keepAliveTimeout` (5s) — see `EXPRESS_*_TIMEOUT_MS` in `.env.example`; set
|
|
135
|
+
`0` to fall back to the Node default for any of them.
|
|
136
|
+
|
|
137
|
+
### F-14 — Error JSON envelope consistency (Fixed in 3.0.0)
|
|
138
|
+
|
|
139
|
+
`error.core` emitted JSON errors as `{ success, status, message }`, diverging from the
|
|
140
|
+
documented response envelope `{ status, code, message, data, meta }` used everywhere else
|
|
141
|
+
(controllers, the validator core, the rate-limit message). Clients had to special-case
|
|
142
|
+
error shapes, and `status` carried a different type (boolean vs numeric code) than the
|
|
143
|
+
rest of the API. 404 and error responses now use the standard envelope (errors still add
|
|
144
|
+
`stack` outside production). This is a response-shape change for error paths in 3.0.0.
|
|
145
|
+
|
|
107
146
|
## Positive controls
|
|
108
147
|
|
|
109
148
|
- `helmet` with a restrictive CSP; `x-powered-by` disabled.
|
|
@@ -13,6 +13,14 @@ const Common = require('@core/common.core')
|
|
|
13
13
|
module.exports = {
|
|
14
14
|
bodyLimit: Common.getEnv('EXPRESS_BODY_LIMIT', '10mb'),
|
|
15
15
|
trustProxy: Common.getEnvInt('EXPRESS_TRUST_PROXY', 0),
|
|
16
|
+
// Socket timeouts guard against slow-request / slowloris DoS. A value of 0 disables
|
|
17
|
+
// the individual timeout (Node default). requestTimeout caps a full request; headers
|
|
18
|
+
// timeout caps the header phase; keepAlive is the idle keep-alive window.
|
|
19
|
+
timeouts: {
|
|
20
|
+
request: Common.getEnvInt('EXPRESS_REQUEST_TIMEOUT_MS', 30000),
|
|
21
|
+
headers: Common.getEnvInt('EXPRESS_HEADERS_TIMEOUT_MS', 15000),
|
|
22
|
+
keepAlive: Common.getEnvInt('EXPRESS_KEEPALIVE_TIMEOUT_MS', 5000),
|
|
23
|
+
},
|
|
16
24
|
rateLimit: {
|
|
17
25
|
enabled: Common.getEnvBool('EXPRESS_RATE_LIMIT_ENABLED', true),
|
|
18
26
|
windowMs: Common.getEnvInt('EXPRESS_RATE_LIMIT_WINDOW_MS', 900000),
|
|
@@ -54,31 +54,44 @@ class Database {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Resolve every directory that may contain model factories: the flat
|
|
58
|
+
// database/models plus each module's models/ dir (additive, opt-in).
|
|
59
|
+
static modelDirs() {
|
|
60
|
+
const Modules = require('@core/modules.core')
|
|
61
|
+
return [MODELS_DIR, ...Modules.dirs('models')].filter((dir) => fs.existsSync(dir))
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
// ── Models ───────────────────────────────────────────────────────────────
|
|
58
65
|
static loadModels() {
|
|
59
|
-
|
|
66
|
+
const dirs = Database.modelDirs()
|
|
67
|
+
if (!dirs.length) {
|
|
60
68
|
logger.warn(`Models directory not found: ${MODELS_DIR}`)
|
|
61
69
|
return Database.models
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
const
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
const files = fs.readdirSync(dir).filter((file) => file.endsWith('.js') && !file.startsWith('_'))
|
|
65
74
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
try {
|
|
77
|
+
const definition = require(path.join(dir, file))
|
|
78
|
+
const factory = definition.default || definition
|
|
79
|
+
|
|
80
|
+
if (typeof factory !== 'function') {
|
|
81
|
+
logger.warn(`Skipped model "${file}": expected a (sequelize, DataTypes) factory export`)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const model = factory(Database.sequelize, DataTypes)
|
|
86
|
+
if (model?.name) {
|
|
87
|
+
if (Database.models.has(model.name)) {
|
|
88
|
+
logger.warn(`Model "${model.name}" from "${file}" overrides an already-registered model of the same name`)
|
|
89
|
+
}
|
|
90
|
+
Database.models.set(model.name, model)
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logger.error(`Failed to load model "${file}"`, err)
|
|
79
94
|
}
|
|
80
|
-
} catch (err) {
|
|
81
|
-
logger.error(`Failed to load model "${file}"`, err)
|
|
82
95
|
}
|
|
83
96
|
}
|
|
84
97
|
|
|
@@ -27,9 +27,11 @@ module.exports = class ErrorHandler {
|
|
|
27
27
|
|
|
28
28
|
if (ErrorHandler._wantsJson(req)) {
|
|
29
29
|
return res.status(status).json({
|
|
30
|
-
|
|
31
|
-
status,
|
|
30
|
+
status: false,
|
|
31
|
+
code: status,
|
|
32
32
|
message,
|
|
33
|
+
data: null,
|
|
34
|
+
meta: null,
|
|
33
35
|
})
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -54,9 +56,11 @@ module.exports = class ErrorHandler {
|
|
|
54
56
|
|
|
55
57
|
if (ErrorHandler._wantsJson(req)) {
|
|
56
58
|
return res.status(status).json({
|
|
57
|
-
|
|
58
|
-
status,
|
|
59
|
+
status: false,
|
|
60
|
+
code: status,
|
|
59
61
|
message,
|
|
62
|
+
data: null,
|
|
63
|
+
meta: null,
|
|
60
64
|
...(detail && { stack: detail }),
|
|
61
65
|
})
|
|
62
66
|
}
|
|
@@ -15,8 +15,9 @@ const config = require('@config/index')
|
|
|
15
15
|
const logger = require('@core/logger.core')
|
|
16
16
|
const ErrorHandler = require('@core/error.core')
|
|
17
17
|
const Register = require('@core/register.core')
|
|
18
|
+
const Modules = require('@core/modules.core')
|
|
18
19
|
|
|
19
|
-
const Routes = require('@
|
|
20
|
+
const Routes = require('@core/routing.core')
|
|
20
21
|
|
|
21
22
|
const MIDDLEWARE_REGISTER = Register.path('app', 'http', 'middlewares', 'register.middleware.js')
|
|
22
23
|
|
|
@@ -53,21 +54,23 @@ class Express {
|
|
|
53
54
|
)
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
Express._applyUserMiddleware(app)
|
|
57
|
+
await Express._applyUserMiddleware(app)
|
|
57
58
|
Express._applyStatic(app)
|
|
58
59
|
await Express._applyRoutes(app)
|
|
59
60
|
Express._applyErrorHandling(app)
|
|
60
61
|
|
|
61
62
|
Express.app = app
|
|
62
63
|
Express.server = http.createServer(app)
|
|
64
|
+
Express._applyTimeouts(Express.server)
|
|
63
65
|
return { app, server: Express.server }
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
// Global application middleware: app/http/middlewares/register.middleware.js
|
|
67
|
-
// exports (app) => { ... }.
|
|
68
|
-
static _applyUserMiddleware(app) {
|
|
69
|
+
// exports (app) => { ... }, then each module's middlewares(app) hook.
|
|
70
|
+
static async _applyUserMiddleware(app) {
|
|
69
71
|
const register = Register.require(MIDDLEWARE_REGISTER, { label: 'app/http/middlewares/register.middleware.js' })
|
|
70
72
|
if (typeof register === 'function') register(app)
|
|
73
|
+
await Modules.invoke('middlewares', [app])
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
// Serve ./public at the root and uploaded files under /uploads. The whole
|
|
@@ -95,6 +98,7 @@ class Express {
|
|
|
95
98
|
// effect, then Routes.apply binds them onto a router and app.use()s it.
|
|
96
99
|
static async _applyRoutes(app) {
|
|
97
100
|
require('@routes/register.route')
|
|
101
|
+
Modules.requireRoutes()
|
|
98
102
|
await Routes.apply(app, express.Router())
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -103,6 +107,14 @@ class Express {
|
|
|
103
107
|
app.use(ErrorHandler.expressErrorHandler)
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
// Apply slow-request / slowloris DoS guards. A value of 0 leaves Node's default.
|
|
111
|
+
static _applyTimeouts(server) {
|
|
112
|
+
const t = config.express.timeouts
|
|
113
|
+
if (t.request) server.requestTimeout = t.request
|
|
114
|
+
if (t.headers) server.headersTimeout = t.headers
|
|
115
|
+
if (t.keepAlive) server.keepAliveTimeout = t.keepAlive
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
static listen() {
|
|
107
119
|
return new Promise((resolve, reject) => {
|
|
108
120
|
if (!Express.server) {
|
|
@@ -21,14 +21,17 @@ class Hooks {
|
|
|
21
21
|
|
|
22
22
|
static async run(stage, ctx = {}) {
|
|
23
23
|
const fn = Hooks._resolve()[stage]
|
|
24
|
-
if (typeof fn
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
if (typeof fn === 'function') {
|
|
25
|
+
try {
|
|
26
|
+
await fn(ctx)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
logger.error(`Hook "${stage}" failed`, err)
|
|
29
|
+
throw err
|
|
30
|
+
}
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
// Module lifecycle hooks run after the flat app hook for the same stage.
|
|
34
|
+
await require('@core/modules.core').runHooks(stage, ctx)
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
static before(ctx) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const logger = require('@core/logger.core')
|
|
7
|
+
const Database = require('@core/database.core')
|
|
8
|
+
const Modules = require('@core/modules.core')
|
|
9
|
+
|
|
10
|
+
const MIGRATIONS_DIR = path.resolve(process.cwd(), 'database', 'migrations')
|
|
11
|
+
const TABLE = '_migrations'
|
|
12
|
+
|
|
13
|
+
// Lightweight migration runner built on Sequelize's QueryInterface (no extra deps).
|
|
14
|
+
// Migration files export { up(qi, Sequelize), down(qi, Sequelize) } and live in
|
|
15
|
+
// database/migrations/ and/or each module's migrations/ dir. Applied migrations are
|
|
16
|
+
// tracked in the `_migrations` table; files are ordered globally by filename, so the
|
|
17
|
+
// `YYYYMMDDHHmmss_` timestamp prefix produced by the CLI gives deterministic order.
|
|
18
|
+
// Files whose name starts with "_" are ignored (helpers/partials).
|
|
19
|
+
class Migrator {
|
|
20
|
+
// Open (or reuse) the pooled connection and return the QueryInterface.
|
|
21
|
+
static async _qi() {
|
|
22
|
+
const sequelize = await Database.connect()
|
|
23
|
+
return { sequelize, qi: sequelize.getQueryInterface(), Sequelize: Database.Sequelize }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static async _ensureTable(qi) {
|
|
27
|
+
const { DataTypes } = Database
|
|
28
|
+
await qi
|
|
29
|
+
.createTable(
|
|
30
|
+
TABLE,
|
|
31
|
+
{
|
|
32
|
+
id: { type: DataTypes.BIGINT.UNSIGNED, primaryKey: true, autoIncrement: true },
|
|
33
|
+
name: { type: DataTypes.STRING(255), allowNull: false, unique: true },
|
|
34
|
+
batch: { type: DataTypes.INTEGER, allowNull: false },
|
|
35
|
+
migrated_at: { type: DataTypes.DATE, allowNull: false },
|
|
36
|
+
},
|
|
37
|
+
{ charset: 'utf8mb4' },
|
|
38
|
+
)
|
|
39
|
+
.catch((err) => {
|
|
40
|
+
// createTable is not idempotent across all dialects; tolerate "already exists".
|
|
41
|
+
if (!/already exists/i.test(err.message)) throw err
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Map of basename -> absolute path across the flat dir and every module dir.
|
|
46
|
+
// Later duplicates of the same filename are warned about and ignored.
|
|
47
|
+
static _discover() {
|
|
48
|
+
const dirs = [MIGRATIONS_DIR, ...Modules.dirs('migrations')]
|
|
49
|
+
const files = new Map()
|
|
50
|
+
|
|
51
|
+
for (const dir of dirs) {
|
|
52
|
+
if (!fs.existsSync(dir)) continue
|
|
53
|
+
for (const file of fs.readdirSync(dir)) {
|
|
54
|
+
if (!file.endsWith('.js') || file.startsWith('_')) continue
|
|
55
|
+
if (files.has(file)) {
|
|
56
|
+
logger.warn(`Duplicate migration filename "${file}" in ${dir} ignored (already provided by ${path.dirname(files.get(file))})`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
files.set(file, path.join(dir, file))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return new Map([...files.entries()].sort(([a], [b]) => a.localeCompare(b)))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async _applied(sequelize) {
|
|
67
|
+
const { QueryTypes } = Database.Sequelize
|
|
68
|
+
const rows = await sequelize.query(`SELECT name, batch FROM ${TABLE} ORDER BY id ASC`, { type: QueryTypes.SELECT })
|
|
69
|
+
return rows
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Run every pending migration in order under a fresh batch number.
|
|
73
|
+
static async up() {
|
|
74
|
+
const { sequelize, qi, Sequelize } = await Migrator._qi()
|
|
75
|
+
await Migrator._ensureTable(qi)
|
|
76
|
+
|
|
77
|
+
const all = Migrator._discover()
|
|
78
|
+
const applied = new Set((await Migrator._applied(sequelize)).map((r) => r.name))
|
|
79
|
+
const pending = [...all.keys()].filter((name) => !applied.has(name))
|
|
80
|
+
|
|
81
|
+
if (!pending.length) {
|
|
82
|
+
logger.info('Migrations: nothing to migrate')
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const batch = await Migrator._nextBatch(sequelize)
|
|
87
|
+
const ran = []
|
|
88
|
+
|
|
89
|
+
for (const name of pending) {
|
|
90
|
+
const mod = Migrator._require(all.get(name))
|
|
91
|
+
logger.info(`Migrating: ${name}`)
|
|
92
|
+
await mod.up(qi, Sequelize)
|
|
93
|
+
await sequelize.query(`INSERT INTO ${TABLE} (name, batch, migrated_at) VALUES (:name, :batch, :now)`, {
|
|
94
|
+
replacements: { name, batch, now: new Date() },
|
|
95
|
+
})
|
|
96
|
+
ran.push(name)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
logger.info(`Migrations: ${ran.length} applied (batch ${batch})`)
|
|
100
|
+
return ran
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Roll back the most recent batch, or the last `step` batches.
|
|
104
|
+
static async down({ step = 1 } = {}) {
|
|
105
|
+
const { sequelize, qi, Sequelize } = await Migrator._qi()
|
|
106
|
+
await Migrator._ensureTable(qi)
|
|
107
|
+
|
|
108
|
+
const all = Migrator._discover()
|
|
109
|
+
const applied = await Migrator._applied(sequelize)
|
|
110
|
+
if (!applied.length) {
|
|
111
|
+
logger.info('Migrations: nothing to roll back')
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const batches = [...new Set(applied.map((r) => r.batch))].sort((a, b) => b - a).slice(0, step)
|
|
116
|
+
const targets = applied
|
|
117
|
+
.filter((r) => batches.includes(r.batch))
|
|
118
|
+
.map((r) => r.name)
|
|
119
|
+
.reverse()
|
|
120
|
+
|
|
121
|
+
const rolled = []
|
|
122
|
+
for (const name of targets) {
|
|
123
|
+
const file = all.get(name)
|
|
124
|
+
if (!file) {
|
|
125
|
+
logger.warn(`Cannot roll back "${name}": file not found; removing tracking row only`)
|
|
126
|
+
} else {
|
|
127
|
+
const mod = Migrator._require(file)
|
|
128
|
+
logger.info(`Rolling back: ${name}`)
|
|
129
|
+
await mod.down(qi, Sequelize)
|
|
130
|
+
}
|
|
131
|
+
await sequelize.query(`DELETE FROM ${TABLE} WHERE name = :name`, { replacements: { name } })
|
|
132
|
+
rolled.push(name)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
logger.info(`Migrations: ${rolled.length} rolled back`)
|
|
136
|
+
return rolled
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Roll back everything (all batches), one batch at a time.
|
|
140
|
+
static async rollbackAll() {
|
|
141
|
+
const { sequelize, qi } = await Migrator._qi()
|
|
142
|
+
await Migrator._ensureTable(qi)
|
|
143
|
+
let guard = 0
|
|
144
|
+
while ((await Migrator._applied(sequelize)).length) {
|
|
145
|
+
await Migrator.down({ step: 1 })
|
|
146
|
+
if (++guard > 1000) throw new Error('rollbackAll exceeded 1000 iterations; aborting')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static async status() {
|
|
151
|
+
const { sequelize, qi } = await Migrator._qi()
|
|
152
|
+
await Migrator._ensureTable(qi)
|
|
153
|
+
const all = [...Migrator._discover().keys()]
|
|
154
|
+
const applied = await Migrator._applied(sequelize)
|
|
155
|
+
const appliedMap = new Map(applied.map((r) => [r.name, r.batch]))
|
|
156
|
+
return all.map((name) => ({ name, applied: appliedMap.has(name), batch: appliedMap.get(name) ?? null }))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Drop every table in the database (used by reset/fresh/wipe). FK constraints are
|
|
160
|
+
// disabled per dialect so order does not matter.
|
|
161
|
+
static async dropAllTables() {
|
|
162
|
+
const { sequelize, qi } = await Migrator._qi()
|
|
163
|
+
const dialect = sequelize.getDialect()
|
|
164
|
+
const tables = (await qi.showAllTables()).map((t) => (typeof t === 'object' ? t.tableName : t))
|
|
165
|
+
|
|
166
|
+
if (!tables.length) {
|
|
167
|
+
logger.info('Migrations: no tables to drop')
|
|
168
|
+
return []
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (dialect === 'mysql' || dialect === 'mariadb') {
|
|
172
|
+
await sequelize.query('SET FOREIGN_KEY_CHECKS = 0')
|
|
173
|
+
for (const t of tables) await qi.dropTable(t)
|
|
174
|
+
await sequelize.query('SET FOREIGN_KEY_CHECKS = 1')
|
|
175
|
+
} else if (dialect === 'postgres') {
|
|
176
|
+
for (const t of tables) await qi.dropTable(t, { cascade: true })
|
|
177
|
+
} else {
|
|
178
|
+
for (const t of tables) await qi.dropTable(t)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
logger.info(`Migrations: dropped ${tables.length} table(s)`)
|
|
182
|
+
return tables
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
static async _nextBatch(sequelize) {
|
|
186
|
+
const { QueryTypes } = Database.Sequelize
|
|
187
|
+
const [row] = await sequelize.query(`SELECT COALESCE(MAX(batch), 0) AS max FROM ${TABLE}`, { type: QueryTypes.SELECT })
|
|
188
|
+
return Number(row.max) + 1
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
static _require(file) {
|
|
192
|
+
const raw = require(file)
|
|
193
|
+
const mod = raw && raw.default !== undefined ? raw.default : raw
|
|
194
|
+
if (!mod || typeof mod.up !== 'function' || typeof mod.down !== 'function') {
|
|
195
|
+
throw new Error(`Migration "${path.basename(file)}" must export { up, down } functions`)
|
|
196
|
+
}
|
|
197
|
+
return mod
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = Migrator
|