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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +4 -0
  3. package/template/README.md +63 -8
  4. package/template/SECURITY.md +39 -0
  5. package/template/app/routes/api.route.js +1 -1
  6. package/template/app/routes/register.route.js +1 -1
  7. package/template/app/routes/web.route.js +1 -1
  8. package/template/app/socket/register.socket.js +0 -2
  9. package/template/config/express.config.js +8 -0
  10. package/template/core/common/string.js +1 -1
  11. package/template/core/cron.core.js +1 -0
  12. package/template/core/database.core.js +30 -17
  13. package/template/core/error.core.js +8 -4
  14. package/template/core/express.core.js +16 -4
  15. package/template/core/hooks.core.js +10 -7
  16. package/template/core/migrator.core.js +201 -0
  17. package/template/core/modules.core.js +167 -0
  18. package/template/core/queue.core.js +1 -0
  19. package/template/core/routing.core.d.ts +273 -0
  20. package/template/core/routing.core.js +666 -0
  21. package/template/core/seeder.core.js +105 -0
  22. package/template/core/socket.core.js +1 -0
  23. package/template/database/migrations/.gitkeep +0 -0
  24. package/template/database/seeders/.gitkeep +0 -0
  25. package/template/docs/Database.md +14 -8
  26. package/template/docs/Express.md +5 -2
  27. package/template/docs/Make.md +46 -0
  28. package/template/docs/Migration.md +56 -0
  29. package/template/docs/Modules.md +96 -0
  30. package/template/docs/README.md +5 -0
  31. package/template/docs/Routing.md +116 -0
  32. package/template/docs/Seeder.md +54 -0
  33. package/template/eslint.config.js +52 -0
  34. package/template/package-lock.json +1068 -70
  35. package/template/package.json +15 -8
  36. package/template/scripts/cli/args.js +39 -0
  37. package/template/scripts/cli/bootstrap.js +16 -0
  38. package/template/scripts/cli/db.js +79 -0
  39. package/template/scripts/cli/help.js +58 -0
  40. package/template/scripts/cli/keys.js +100 -0
  41. package/template/scripts/cli/log.js +58 -0
  42. package/template/scripts/cli/make.js +249 -0
  43. package/template/scripts/cli/names.js +51 -0
  44. package/template/scripts/cli/templates.js +358 -0
  45. package/template/scripts/cli.js +75 -234
  46. package/template/tests/http.test.js +99 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rebe",
3
- "version": "1.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Scaffold a new rebe backend project: npm create rebe@latest <dir>.",
5
5
  "license": "MIT",
6
6
  "author": "Refkinscallv <refkinscallv@gmail.com>",
@@ -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
@@ -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, CORS, compression, rate
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, refresh deps, generate keys
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` and generates `APP_KEY`, `JWT_SECRET`, and
42
- `JWT_REFRESH_SECRET`. Generate keys individually with `npm run cli -- key:generate`
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/ # Sequelize models -> @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
@@ -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.
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const Routes = require('@refkinscallv/express-routing')
3
+ const Routes = require('@core/routing.core')
4
4
 
5
5
  const Validator = require('@core/validator.core')
6
6
  const AuthController = require('@http/controllers/auth.controller')
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const Routes = require('@refkinscallv/express-routing')
3
+ const Routes = require('@core/routing.core')
4
4
 
5
5
  require('@routes/web.route')
6
6
  require('@routes/api.route')
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const Routes = require('@refkinscallv/express-routing')
3
+ const Routes = require('@core/routing.core')
4
4
 
5
5
  Routes.get('', ({ res }) => {
6
6
  res.send('Hello World')
@@ -1,7 +1,5 @@
1
1
  'use strict'
2
2
 
3
- const logger = require('@core/logger.core')
4
-
5
3
  // Socket.IO connection handlers. The socket core calls this with the io server and
6
4
  // the Socket facade after the server is attached. Register namespaces, rooms, and
7
5
  // event listeners here.
@@ -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),
@@ -27,7 +27,7 @@ class Str {
27
27
  static words(str) {
28
28
  return String(str ?? '')
29
29
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
30
- .replace(/[_\-]+/g, ' ')
30
+ .replace(/[_-]+/g, ' ')
31
31
  .trim()
32
32
  .split(/\s+/)
33
33
  .filter(Boolean)
@@ -41,6 +41,7 @@ class Cron {
41
41
  }
42
42
 
43
43
  await Register.invoke(REGISTER_FILE, [Cron], { label: 'app/jobs/register.job.js' })
44
+ await require('@core/modules.core').invoke('jobs', [Cron])
44
45
 
45
46
  if (config.cron.history && config.db.enabled) {
46
47
  try {
@@ -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
- if (!fs.existsSync(MODELS_DIR)) {
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 files = fs.readdirSync(MODELS_DIR).filter((file) => file.endsWith('.js') && !file.startsWith('_'))
72
+ for (const dir of dirs) {
73
+ const files = fs.readdirSync(dir).filter((file) => file.endsWith('.js') && !file.startsWith('_'))
65
74
 
66
- for (const file of files) {
67
- try {
68
- const definition = require(path.join(MODELS_DIR, file))
69
- const factory = definition.default || definition
70
-
71
- if (typeof factory !== 'function') {
72
- logger.warn(`Skipped model "${file}": expected a (sequelize, DataTypes) factory export`)
73
- continue
74
- }
75
-
76
- const model = factory(Database.sequelize, DataTypes)
77
- if (model?.name) {
78
- Database.models.set(model.name, model)
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
- success: false,
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
- success: false,
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('@refkinscallv/express-routing')
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 !== 'function') return
25
-
26
- try {
27
- await fn(ctx)
28
- } catch (err) {
29
- logger.error(`Hook "${stage}" failed`, err)
30
- throw err
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