odac 1.4.1 → 1.4.2

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 (76) hide show
  1. package/.agent/rules/memory.md +5 -0
  2. package/.releaserc.js +9 -2
  3. package/CHANGELOG.md +35 -0
  4. package/bin/odac.js +3 -2
  5. package/client/odac.js +32 -13
  6. package/docs/ai/skills/backend/database.md +19 -0
  7. package/docs/ai/skills/backend/forms.md +107 -13
  8. package/docs/ai/skills/backend/migrations.md +8 -2
  9. package/docs/ai/skills/backend/validation.md +132 -32
  10. package/docs/ai/skills/frontend/forms.md +43 -15
  11. package/docs/backend/08-database/02-basics.md +49 -9
  12. package/docs/backend/08-database/04-migrations.md +1 -0
  13. package/package.json +1 -1
  14. package/src/Auth.js +15 -2
  15. package/src/Database/ConnectionFactory.js +1 -0
  16. package/src/Database/Migration.js +26 -1
  17. package/src/Database/nanoid.js +30 -0
  18. package/src/Database.js +122 -11
  19. package/src/Ipc.js +37 -0
  20. package/src/Odac.js +1 -1
  21. package/src/Route/Cron.js +11 -0
  22. package/src/Route.js +8 -0
  23. package/src/Server.js +77 -23
  24. package/src/Storage.js +15 -1
  25. package/src/Validator.js +22 -20
  26. package/test/{Auth.test.js → Auth/check.test.js} +91 -5
  27. package/test/Client/data.test.js +91 -0
  28. package/test/Client/get.test.js +90 -0
  29. package/test/Client/storage.test.js +87 -0
  30. package/test/Client/token.test.js +82 -0
  31. package/test/Client/ws.test.js +86 -0
  32. package/test/Config/deepMerge.test.js +14 -0
  33. package/test/Config/init.test.js +66 -0
  34. package/test/Config/interpolate.test.js +35 -0
  35. package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
  36. package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
  37. package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
  38. package/test/Database/Migration/migrate_column.test.js +52 -0
  39. package/test/Database/Migration/migrate_files.test.js +70 -0
  40. package/test/Database/Migration/migrate_index.test.js +89 -0
  41. package/test/Database/Migration/migrate_nanoid.test.js +160 -0
  42. package/test/Database/Migration/migrate_seed.test.js +77 -0
  43. package/test/Database/Migration/migrate_table.test.js +88 -0
  44. package/test/Database/Migration/rollback.test.js +61 -0
  45. package/test/Database/Migration/snapshot.test.js +38 -0
  46. package/test/Database/Migration/status.test.js +41 -0
  47. package/test/Database/autoNanoid.test.js +215 -0
  48. package/test/Database/nanoid.test.js +19 -0
  49. package/test/Lang/constructor.test.js +25 -0
  50. package/test/Lang/get.test.js +65 -0
  51. package/test/Lang/set.test.js +49 -0
  52. package/test/Odac/init.test.js +42 -0
  53. package/test/Odac/instance.test.js +58 -0
  54. package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
  55. package/test/Route/Middleware/use.test.js +35 -0
  56. package/test/{Route.test.js → Route/check.test.js} +4 -55
  57. package/test/Route/set.test.js +52 -0
  58. package/test/Route/ws.test.js +23 -0
  59. package/test/View/EarlyHints/cache.test.js +32 -0
  60. package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
  61. package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
  62. package/test/View/EarlyHints/send.test.js +99 -0
  63. package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
  64. package/test/View/constructor.test.js +22 -0
  65. package/test/View/print.test.js +19 -0
  66. package/test/WebSocket/Client/limits.test.js +55 -0
  67. package/test/WebSocket/Server/broadcast.test.js +33 -0
  68. package/test/WebSocket/Server/route.test.js +37 -0
  69. package/test/Client.test.js +0 -197
  70. package/test/Config.test.js +0 -119
  71. package/test/Database/ConnectionFactory.test.js +0 -80
  72. package/test/Lang.test.js +0 -92
  73. package/test/Migration.test.js +0 -943
  74. package/test/Odac.test.js +0 -88
  75. package/test/View/EarlyHints.test.js +0 -282
  76. package/test/WebSocket.test.js +0 -238
@@ -118,19 +118,59 @@ await Odac.DB.users.where('id', 1).delete();
118
118
 
119
119
  ODAC includes a built-in helper for generating robust, unique string IDs (NanoID) without needing external packages. Secure, URL-friendly, and collision-resistant.
120
120
 
121
+ ### Automatic Generation (Recommended)
122
+
123
+ When you define a column as `type: 'nanoid'` in your schema file, ODAC **automatically generates** the ID on every `insert()` — no manual code needed.
124
+
125
+ **Schema definition:**
121
126
  ```javascript
122
- // Generate a standard 21-character ID (e.g., "V1StGXR8_Z5jdHi6B-myT")
123
- const id = Odac.DB.nanoid();
127
+ // schema/posts.js
128
+ module.exports = {
129
+ columns: {
130
+ id: { type: 'nanoid', primary: true },
131
+ title: { type: 'string', length: 255 }
132
+ }
133
+ }
134
+ ```
124
135
 
125
- // Generate a custom length ID
126
- const shortId = Odac.DB.nanoid(10);
136
+ **Usage just insert, the ID is auto-generated:**
137
+ ```javascript
138
+ await Odac.DB.posts.insert({ title: 'My First Post' });
139
+ // → { id: 'V1StGXR8Z5jdHi6BmyTa', title: 'My First Post' }
127
140
  ```
128
141
 
129
- This is particularly useful when inserting records into tables that use string-based Primary Keys instead of auto-increment integers.
142
+ This works for single inserts and bulk inserts. If you provide an `id` explicitly, the auto-generation is skipped.
130
143
 
131
144
  ```javascript
132
- await Odac.DB.posts.insert({
133
- id: Odac.DB.nanoid(),
134
- title: 'My First Post'
135
- });
145
+ // Bulk insert — each row gets its own unique nanoid
146
+ await Odac.DB.posts.insert([
147
+ { title: 'Post A' },
148
+ { title: 'Post B' }
149
+ ]);
150
+
151
+ // Explicit ID — auto-generation is skipped
152
+ await Odac.DB.posts.insert({ id: 'my-custom-id', title: 'Custom' });
153
+ ```
154
+
155
+ You can also customize the ID length:
156
+ ```javascript
157
+ // schema/codes.js
158
+ module.exports = {
159
+ columns: {
160
+ code: { type: 'nanoid', length: 8, primary: true },
161
+ label: { type: 'string' }
162
+ }
163
+ }
164
+ ```
165
+
166
+ ### Manual Generation
167
+
168
+ You can also generate NanoIDs manually when needed:
169
+
170
+ ```javascript
171
+ // Generate a standard 21-character ID
172
+ const id = Odac.DB.nanoid();
173
+
174
+ // Generate a custom length ID
175
+ const shortId = Odac.DB.nanoid(10);
136
176
  ```
@@ -91,6 +91,7 @@ npx odac migrate
91
91
  |------|-------|---------|
92
92
  | `increments` | Auto-increment primary key | — |
93
93
  | `bigIncrements` | Big auto-increment | — |
94
+ | `nanoid` | NanoID string key (auto-generated on insert) | `length` (default: 21) |
94
95
  | `integer` | Integer | `unsigned` |
95
96
  | `bigInteger` | Big integer | `unsigned` |
96
97
  | `float` | Floating point | `precision`, `scale` |
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.1",
10
+ "version": "1.4.2",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
package/src/Auth.js CHANGED
@@ -143,9 +143,22 @@ class Auth {
143
143
  let triggerRotation = false
144
144
  let isRecoveryRotation = false
145
145
 
146
+ // WebSocket connections (res === null) cannot deliver Set-Cookie headers.
147
+ // Rotating a token during a WS upgrade would invalidate the browser's cookies
148
+ // with no way to deliver replacements, causing silent logout on the next HTTP request.
149
+ const canDeliverCookies = !!this.#request.res
150
+
146
151
  if (!isRotated) {
147
152
  if (shouldRotate && tokenAge > rotationAge) {
148
- triggerRotation = true
153
+ if (canDeliverCookies) {
154
+ triggerRotation = true
155
+ } else {
156
+ // WebSocket: Can't deliver rotated cookies, refresh active timestamp instead
157
+ Odac.DB[tokenTable]
158
+ .where('id', sql_token[0].id)
159
+ .update({active: new Date()})
160
+ .catch(() => {})
161
+ }
149
162
  } else if (inactiveAge > updateAge) {
150
163
  // Fallback simple active update if rotation is not triggered
151
164
  Odac.DB[tokenTable]
@@ -158,7 +171,7 @@ class Auth {
158
171
  // This means the previous rotation response was lost (network hiccup, page navigation, etc.)
159
172
  // Give the client one more chance by re-issuing new credentials.
160
173
  const timeSinceRotation = inactiveAge - maxAge + TOKEN_ROTATION_GRACE_PERIOD_MS
161
- if (timeSinceRotation > 5000) {
174
+ if (timeSinceRotation > 5000 && canDeliverCookies) {
162
175
  triggerRotation = true
163
176
  isRecoveryRotation = true
164
177
  }
@@ -57,6 +57,7 @@ function buildConnections(databaseConfig) {
57
57
  pool: {min: 0, max: db.connectionLimit || 10},
58
58
  useNullAsDefault: true
59
59
  })
60
+ connections[key]._odacConnectionKey = key
60
61
  }
61
62
 
62
63
  return connections
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs')
4
4
  const path = require('node:path')
5
+ const nanoid = require('./nanoid')
5
6
 
6
7
  /**
7
8
  * ODAC Migration Engine — "Schema-First with Auto-Diff"
@@ -213,7 +214,8 @@ class Migration {
213
214
 
214
215
  for (const [colName, colDef] of Object.entries(columns)) {
215
216
  if (!colDef.unique) continue
216
- if (colDef.type === 'timestamps' || colDef.type === 'increments' || colDef.type === 'bigIncrements') continue
217
+ if (colDef.type === 'timestamps' || colDef.type === 'increments' || colDef.type === 'bigIncrements' || colDef.type === 'nanoid')
218
+ continue
217
219
 
218
220
  const implicitIdx = {columns: [colName], unique: true}
219
221
  const sig = this._indexSignature(implicitIdx)
@@ -678,6 +680,8 @@ class Migration {
678
680
  return table.json(colName)
679
681
  case 'jsonb':
680
682
  return table.jsonb(colName)
683
+ case 'nanoid':
684
+ return table.string(colName, def.length || 21)
681
685
  case 'uuid':
682
686
  return table.uuid(colName)
683
687
  case 'enum':
@@ -905,6 +909,9 @@ class Migration {
905
909
  const existing = await knex(tableName).where(seedKey, keyValue).first()
906
910
 
907
911
  if (!existing) {
912
+ // Auto-generate nanoid for columns with type 'nanoid' that are missing from seed data
913
+ this._fillNanoidColumns(preparedRow, schema)
914
+
908
915
  if (!dryRun) {
909
916
  await knex(tableName).insert(preparedRow)
910
917
  }
@@ -1198,6 +1205,24 @@ class Migration {
1198
1205
  table.index(['connection', 'type'])
1199
1206
  })
1200
1207
  }
1208
+
1209
+ /**
1210
+ * Populates missing nanoid columns in a data row before insertion.
1211
+ * Why: Zero-config DX — developers should not manually call nanoid() for every insert.
1212
+ * When a schema defines a column as type 'nanoid', the framework auto-generates
1213
+ * the value if the caller did not provide one.
1214
+ * @param {object} row - Data row to mutate in-place
1215
+ * @param {object} schema - Table schema definition
1216
+ */
1217
+ _fillNanoidColumns(row, schema) {
1218
+ const columns = schema.columns || {}
1219
+
1220
+ for (const [colName, colDef] of Object.entries(columns)) {
1221
+ if (colDef.type === 'nanoid' && !row[colName]) {
1222
+ row[colName] = nanoid(colDef.length || 21)
1223
+ }
1224
+ }
1225
+ }
1201
1226
  }
1202
1227
 
1203
1228
  module.exports = new Migration()
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ const nodeCrypto = require('node:crypto')
4
+
5
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
6
+
7
+ /**
8
+ * Generates a cryptographically secure, URL-safe alphanumeric NanoID.
9
+ * Why: Centralized implementation shared by Database.js and Migration.js
10
+ * to avoid code duplication. Uses rejection sampling on crypto.randomBytes
11
+ * for uniform distribution across a 62-character alphabet.
12
+ * @param {number} size - Desired ID length (default: 21)
13
+ * @returns {string} URL-safe alphanumeric ID
14
+ */
15
+ function nanoid(size = 21) {
16
+ let id = ''
17
+ while (id.length < size) {
18
+ const bytes = nodeCrypto.randomBytes(size + 5)
19
+ for (let i = 0; i < bytes.length; i++) {
20
+ const byte = bytes[i] & 63
21
+ if (byte < 62) {
22
+ id += ALPHABET[byte]
23
+ if (id.length === size) break
24
+ }
25
+ }
26
+ }
27
+ return id
28
+ }
29
+
30
+ module.exports = nanoid
package/src/Database.js CHANGED
@@ -1,9 +1,12 @@
1
1
  'use strict'
2
2
  const {buildConnections} = require('./Database/ConnectionFactory')
3
+ const nanoid = require('./Database/nanoid')
3
4
 
4
5
  class DatabaseManager {
5
6
  constructor() {
6
7
  this.connections = {}
8
+ /** @type {Object<string, Object<string, Array<{column: string, size: number}>>>} connectionKey -> tableName -> nanoid columns */
9
+ this._nanoidColumns = {}
7
10
  }
8
11
 
9
12
  async init() {
@@ -24,6 +27,10 @@ class DatabaseManager {
24
27
  // Auto-migrate: sync schema/ files with the database on every startup.
25
28
  // Why: Zero-config philosophy — deploy and forget. The app always starts with the correct DB state.
26
29
  await this._autoMigrate()
30
+
31
+ // Cache nanoid column metadata from schema files for insert-time auto-generation.
32
+ // Runs on ALL processes (primary + workers) since every process may insert data.
33
+ this._loadNanoidMeta()
27
34
  }
28
35
 
29
36
  /**
@@ -54,21 +61,92 @@ class DatabaseManager {
54
61
  }
55
62
  }
56
63
 
64
+ /**
65
+ * Gracefully destroys all active database connections.
66
+ * Called during shutdown to release connection pools and prevent resource leaks.
67
+ */
68
+ async close() {
69
+ const entries = Object.entries(this.connections)
70
+ if (entries.length === 0) return
71
+
72
+ await Promise.allSettled(
73
+ entries.map(([name, knex]) =>
74
+ knex.destroy().catch(err => {
75
+ console.error(`\x1b[31m[Database]\x1b[0m Failed to close '${name}' connection:`, err.message)
76
+ })
77
+ )
78
+ )
79
+ this.connections = {}
80
+ }
81
+
57
82
  nanoid(size = 21) {
58
- const nodeCrypto = require('crypto')
59
- const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
60
- let id = ''
61
- while (id.length < size) {
62
- const bytes = nodeCrypto.randomBytes(size + 5)
63
- for (let i = 0; i < bytes.length; i++) {
64
- const byte = bytes[i] & 63
65
- if (byte < 62) {
66
- id += alphabet[byte]
67
- if (id.length === size) break
83
+ return nanoid(size)
84
+ }
85
+
86
+ /**
87
+ * Scans schema/ directory and caches which columns are type 'nanoid' per table.
88
+ * Why: The insert() proxy needs O(1) lookup to auto-generate IDs at runtime.
89
+ * Lightweight only reads file metadata, no DB introspection.
90
+ */
91
+ _loadNanoidMeta() {
92
+ const fs = require('node:fs')
93
+ const path = require('node:path')
94
+ const Module = require('node:module')
95
+
96
+ if (!global.__dir) return
97
+ const schemaDir = path.join(global.__dir, 'schema')
98
+ if (!fs.existsSync(schemaDir)) return
99
+
100
+ const loadDir = (dir, connectionKey) => {
101
+ if (!this._nanoidColumns[connectionKey]) {
102
+ this._nanoidColumns[connectionKey] = {}
103
+ }
104
+
105
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.js') && fs.statSync(path.join(dir, f)).isFile())
106
+
107
+ for (const file of files) {
108
+ const filePath = path.join(dir, file)
109
+ const tableName = path.basename(file, '.js')
110
+
111
+ try {
112
+ const source = fs.readFileSync(filePath, 'utf8')
113
+ const m = new Module(filePath)
114
+ m.filename = filePath
115
+ m.paths = Module._nodeModulePaths(path.dirname(filePath))
116
+ m._compile(source, filePath)
117
+ const schema = m.exports
118
+
119
+ if (!schema?.columns) continue
120
+
121
+ const nanoidCols = []
122
+ for (const [colName, colDef] of Object.entries(schema.columns)) {
123
+ if (colDef.type === 'nanoid') {
124
+ nanoidCols.push({column: colName, size: colDef.length || 21})
125
+ }
126
+ }
127
+
128
+ if (nanoidCols.length > 0) {
129
+ this._nanoidColumns[connectionKey][tableName] = nanoidCols
130
+ }
131
+ } catch (e) {
132
+ // Schema file parse error — skip silently, Migration will report it
133
+ if (global.Odac?.Config?.debug) {
134
+ console.warn(`\x1b[33m[ODAC NanoID Meta]\x1b[0m Failed to parse schema ${filePath}:`, e.message)
135
+ }
68
136
  }
69
137
  }
70
138
  }
71
- return id
139
+
140
+ // Root-level files (default connection)
141
+ loadDir(schemaDir, 'default')
142
+
143
+ // Subdirectories (named connections)
144
+ const entries = fs.readdirSync(schemaDir, {withFileTypes: true})
145
+ for (const entry of entries) {
146
+ if (entry.isDirectory()) {
147
+ loadDir(path.join(schemaDir, entry.name), entry.name)
148
+ }
149
+ }
72
150
  }
73
151
  }
74
152
 
@@ -104,6 +182,28 @@ const tableProxyHandler = {
104
182
  return originalCount.apply(this, args)
105
183
  }
106
184
 
185
+ // Odac DX Improvement: Auto-generate NanoID for columns defined as type 'nanoid' in schema.
186
+ // Why: Zero-config ID generation — no manual Odac.DB.nanoid() calls needed.
187
+ const connectionKey = knexInstance._odacConnectionKey || 'default'
188
+ const nanoidCols = manager._nanoidColumns[connectionKey]?.[prop]
189
+ if (nanoidCols) {
190
+ const originalInsert = qb.insert
191
+ qb.insert = function (data, ...args) {
192
+ if (Array.isArray(data)) {
193
+ for (const row of data) {
194
+ for (const {column, size} of nanoidCols) {
195
+ if (!row[column]) row[column] = manager.nanoid(size)
196
+ }
197
+ }
198
+ } else if (data && typeof data === 'object') {
199
+ for (const {column, size} of nanoidCols) {
200
+ if (!data[column]) data[column] = manager.nanoid(size)
201
+ }
202
+ }
203
+ return originalInsert.call(this, data, ...args)
204
+ }
205
+ }
206
+
107
207
  const originalThen = qb.then
108
208
  qb.then = function (resolve, reject) {
109
209
  if (this._odacIsCount) {
@@ -152,7 +252,10 @@ const rootProxy = new Proxy(manager, {
152
252
  get(target, prop) {
153
253
  // Access to internal manager methods
154
254
  if (prop === 'init') return target.init.bind(target)
255
+ if (prop === 'close') return target.close.bind(target)
155
256
  if (prop === 'connections') return target.connections
257
+ if (prop === '_nanoidColumns') return target._nanoidColumns
258
+ if (prop === '_loadNanoidMeta') return target._loadNanoidMeta.bind(target)
156
259
 
157
260
  // Access to specific database connection: Odac.DB.analytics
158
261
  if (target.connections[prop]) {
@@ -182,6 +285,14 @@ const rootProxy = new Proxy(manager, {
182
285
  }
183
286
 
184
287
  return undefined
288
+ },
289
+
290
+ set(target, prop, value) {
291
+ if (prop === 'connections' || prop === '_nanoidColumns') {
292
+ target[prop] = value
293
+ return true
294
+ }
295
+ return false
185
296
  }
186
297
  })
187
298
 
package/src/Ipc.js CHANGED
@@ -269,6 +269,43 @@ class Ipc extends EventEmitter {
269
269
  interval.unref()
270
270
  }
271
271
 
272
+ /**
273
+ * Tears down IPC resources. For Redis driver, disconnects clients.
274
+ * For memory driver, clears stores and removes cluster listeners.
275
+ */
276
+ async close() {
277
+ if (this.config.driver === 'redis') {
278
+ if (this.subRedis) {
279
+ await this.subRedis.quit().catch(() => {})
280
+ this.subRedis = null
281
+ }
282
+ if (this.redis) {
283
+ await this.redis.quit().catch(() => {})
284
+ this.redis = null
285
+ }
286
+ } else if (cluster.isPrimary) {
287
+ if (global.__odac_ipc_message_handler) {
288
+ cluster.removeListener('message', global.__odac_ipc_message_handler)
289
+ global.__odac_ipc_message_handler = null
290
+ }
291
+ if (global.__odac_ipc_exit_handler) {
292
+ cluster.removeListener('exit', global.__odac_ipc_exit_handler)
293
+ global.__odac_ipc_exit_handler = null
294
+ }
295
+ if (this._memoryStore) this._memoryStore.clear()
296
+ if (this._memorySubs) this._memorySubs.clear()
297
+ } else {
298
+ // Worker: reject all pending requests so they don't hang
299
+ for (const req of this._requests.values()) {
300
+ clearTimeout(req.timeout)
301
+ req.reject(new Error('IPC shutting down'))
302
+ }
303
+ this._requests.clear()
304
+ this._subs.clear()
305
+ }
306
+ this.initialized = false
307
+ }
308
+
272
309
  _handlePrimaryMessage(worker, msg) {
273
310
  const {type, id, key, value, ttl, channel, message} = msg
274
311
  const action = type.replace('ipc:', '')
package/src/Odac.js CHANGED
@@ -149,7 +149,7 @@ module.exports = {
149
149
  return hash ? _odac.Token.check(hash) : _odac.Token.generate()
150
150
  }
151
151
  _odac.validator = function () {
152
- return new (require('./Validator.js'))(_odac.Request)
152
+ return new (require('./Validator.js'))(_odac.Request, _odac)
153
153
  }
154
154
  _odac.write = function (value) {
155
155
  return _odac.Request.write(value)
package/src/Route/Cron.js CHANGED
@@ -12,6 +12,17 @@ class Cron {
12
12
  }
13
13
  }
14
14
 
15
+ /**
16
+ * Stops the cron scheduler. Called during graceful shutdown to prevent
17
+ * new cron jobs from firing while the process is terminating.
18
+ */
19
+ stop() {
20
+ if (this.#interval) {
21
+ clearInterval(this.#interval)
22
+ this.#interval = null
23
+ }
24
+ }
25
+
15
26
  check() {
16
27
  const now = new Date()
17
28
  const minute = now.getMinutes()
package/src/Route.js CHANGED
@@ -806,6 +806,14 @@ class Route {
806
806
  return Cron.job(controller)
807
807
  }
808
808
 
809
+ /**
810
+ * Stops the cron scheduler during graceful shutdown.
811
+ * Prevents new cron jobs from spawning while the process is terminating.
812
+ */
813
+ stopCron() {
814
+ Cron.stop()
815
+ }
816
+
809
817
  ws(path, handler, options = {}) {
810
818
  this.setWs('ws', path, handler, options)
811
819
  return this
package/src/Server.js CHANGED
@@ -33,36 +33,73 @@ module.exports = {
33
33
  }
34
34
  })
35
35
 
36
- // Graceful shutdown handler for primary
37
- const gracefulShutdown = signal => {
36
+ /**
37
+ * GRACEFUL SHUTDOWN PRIMARY PROCESS
38
+ * ────────────────────────────────────
39
+ * Shutdown order (deterministic, sequential):
40
+ * 1. Stop accepting new work → Cron, session GC
41
+ * 2. Drain active workers → send 'shutdown', disconnect, wait
42
+ * 3. Release shared resources → IPC, Database, Storage
43
+ * 4. Exit 0
44
+ *
45
+ * A 30-second hard timeout protects against hung workers or I/O.
46
+ */
47
+ const gracefulShutdown = async signal => {
38
48
  if (isShuttingDown) return
39
49
  isShuttingDown = true
40
50
 
41
51
  console.log(`\n\x1b[33m[Shutdown]\x1b[0m ${signal} received, shutting down gracefully...`)
42
52
 
43
- // Disconnect all workers
44
- for (const id in cluster.workers) {
45
- cluster.workers[id].send('shutdown')
46
- cluster.workers[id].disconnect()
47
- }
53
+ // Force exit safety net — must be set immediately
54
+ const forceTimer = setTimeout(() => {
55
+ console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
56
+ process.exit(1)
57
+ }, 30000)
58
+ forceTimer.unref()
48
59
 
49
- let workersAlive = Object.keys(cluster.workers).length
60
+ // Phase 1: Stop schedulers so no new work is queued
61
+ Odac.Route.stopCron()
62
+ Odac.Storage.stopSessionGC()
50
63
 
51
- cluster.on('exit', () => {
52
- workersAlive--
53
- if (workersAlive === 0) {
54
- console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
55
- Odac.Storage.close()
56
- console.log('\x1b[32m[Shutdown]\x1b[0m Storage closed. Goodbye!')
57
- process.exit(0)
64
+ // Phase 2: Gracefully drain all workers
65
+ await new Promise(resolve => {
66
+ const workerIds = Object.keys(cluster.workers)
67
+ let workersAlive = workerIds.length
68
+
69
+ if (workersAlive === 0) return resolve()
70
+
71
+ cluster.on('exit', () => {
72
+ workersAlive--
73
+ if (workersAlive <= 0) resolve()
74
+ })
75
+
76
+ for (const id of workerIds) {
77
+ const worker = cluster.workers[id]
78
+ if (worker) {
79
+ worker.send('shutdown')
80
+ worker.disconnect()
81
+ }
58
82
  }
59
83
  })
60
84
 
61
- // Force exit after 30 seconds
62
- setTimeout(() => {
63
- console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
64
- process.exit(1)
65
- }, 30000)
85
+ console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
86
+
87
+ // Phase 3: Release shared resources (order matters: IPC → DB → Storage)
88
+ try {
89
+ await Odac.Ipc.close()
90
+ } catch (e) {
91
+ console.error(`\x1b[31m[Shutdown]\x1b[0m Error closing IPC: ${e.message}`)
92
+ }
93
+ try {
94
+ await Odac.Database.close()
95
+ } catch (e) {
96
+ console.error(`\x1b[31m[Shutdown]\x1b[0m Error closing Database: ${e.message}`)
97
+ }
98
+ Odac.Storage.close()
99
+
100
+ console.log('\x1b[32m[Shutdown]\x1b[0m Resources released. Goodbye!')
101
+ clearTimeout(forceTimer)
102
+ process.exit(0)
66
103
  }
67
104
 
68
105
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
@@ -113,11 +150,28 @@ module.exports = {
113
150
 
114
151
  server.listen(port)
115
152
 
116
- // Graceful shutdown handler for worker
117
- process.on('message', msg => {
153
+ /**
154
+ * GRACEFUL SHUTDOWN — WORKER PROCESS
155
+ * ──────────────────────────────────
156
+ * 1. Stop accepting new connections (server.close)
157
+ * 2. Release worker-scoped resources (IPC pending requests, DB pools)
158
+ * 3. Exit 0
159
+ */
160
+ process.on('message', async msg => {
118
161
  if (msg === 'shutdown') {
119
162
  console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Closing server...`)
120
- server.close(() => {
163
+
164
+ server.close(async () => {
165
+ try {
166
+ await Odac.Ipc.close()
167
+ } catch {
168
+ /* best-effort */
169
+ }
170
+ try {
171
+ await Odac.Database.close()
172
+ } catch {
173
+ /* best-effort */
174
+ }
121
175
  console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Server closed.`)
122
176
  process.exit(0)
123
177
  })
package/src/Storage.js CHANGED
@@ -5,6 +5,7 @@ class OdacStorage {
5
5
  constructor() {
6
6
  this.db = null
7
7
  this.ready = false
8
+ this.gcInterval = null
8
9
  }
9
10
 
10
11
  init() {
@@ -72,10 +73,14 @@ class OdacStorage {
72
73
  return null
73
74
  }
74
75
 
76
+ if (this.gcInterval) {
77
+ clearInterval(this.gcInterval)
78
+ }
79
+
75
80
  const BATCH_THRESHOLD = 10000
76
81
  const BATCH_SIZE = 1000
77
82
 
78
- return setInterval(() => {
83
+ this.gcInterval = setInterval(() => {
79
84
  try {
80
85
  // Count sessions to decide mode
81
86
  let sessionCount = 0
@@ -94,6 +99,15 @@ class OdacStorage {
94
99
  console.error('\x1b[31m[Storage GC Error]\x1b[0m', error.message)
95
100
  }
96
101
  }, intervalMs)
102
+
103
+ return this.gcInterval
104
+ }
105
+
106
+ stopSessionGC() {
107
+ if (this.gcInterval) {
108
+ clearInterval(this.gcInterval)
109
+ this.gcInterval = null
110
+ }
97
111
  }
98
112
 
99
113
  // Simple mode: Load all sessions at once (fast for small datasets)