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.
- package/.agent/rules/memory.md +5 -0
- package/.releaserc.js +9 -2
- package/CHANGELOG.md +35 -0
- package/bin/odac.js +3 -2
- package/client/odac.js +32 -13
- package/docs/ai/skills/backend/database.md +19 -0
- package/docs/ai/skills/backend/forms.md +107 -13
- package/docs/ai/skills/backend/migrations.md +8 -2
- package/docs/ai/skills/backend/validation.md +132 -32
- package/docs/ai/skills/frontend/forms.md +43 -15
- package/docs/backend/08-database/02-basics.md +49 -9
- package/docs/backend/08-database/04-migrations.md +1 -0
- package/package.json +1 -1
- package/src/Auth.js +15 -2
- package/src/Database/ConnectionFactory.js +1 -0
- package/src/Database/Migration.js +26 -1
- package/src/Database/nanoid.js +30 -0
- package/src/Database.js +122 -11
- package/src/Ipc.js +37 -0
- package/src/Odac.js +1 -1
- package/src/Route/Cron.js +11 -0
- package/src/Route.js +8 -0
- package/src/Server.js +77 -23
- package/src/Storage.js +15 -1
- package/src/Validator.js +22 -20
- package/test/{Auth.test.js → Auth/check.test.js} +91 -5
- package/test/Client/data.test.js +91 -0
- package/test/Client/get.test.js +90 -0
- package/test/Client/storage.test.js +87 -0
- package/test/Client/token.test.js +82 -0
- package/test/Client/ws.test.js +86 -0
- package/test/Config/deepMerge.test.js +14 -0
- package/test/Config/init.test.js +66 -0
- package/test/Config/interpolate.test.js +35 -0
- package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
- package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
- package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
- package/test/Database/Migration/migrate_column.test.js +52 -0
- package/test/Database/Migration/migrate_files.test.js +70 -0
- package/test/Database/Migration/migrate_index.test.js +89 -0
- package/test/Database/Migration/migrate_nanoid.test.js +160 -0
- package/test/Database/Migration/migrate_seed.test.js +77 -0
- package/test/Database/Migration/migrate_table.test.js +88 -0
- package/test/Database/Migration/rollback.test.js +61 -0
- package/test/Database/Migration/snapshot.test.js +38 -0
- package/test/Database/Migration/status.test.js +41 -0
- package/test/Database/autoNanoid.test.js +215 -0
- package/test/Database/nanoid.test.js +19 -0
- package/test/Lang/constructor.test.js +25 -0
- package/test/Lang/get.test.js +65 -0
- package/test/Lang/set.test.js +49 -0
- package/test/Odac/init.test.js +42 -0
- package/test/Odac/instance.test.js +58 -0
- package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
- package/test/Route/Middleware/use.test.js +35 -0
- package/test/{Route.test.js → Route/check.test.js} +4 -55
- package/test/Route/set.test.js +52 -0
- package/test/Route/ws.test.js +23 -0
- package/test/View/EarlyHints/cache.test.js +32 -0
- package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
- package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
- package/test/View/EarlyHints/send.test.js +99 -0
- package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
- package/test/View/constructor.test.js +22 -0
- package/test/View/print.test.js +19 -0
- package/test/WebSocket/Client/limits.test.js +55 -0
- package/test/WebSocket/Server/broadcast.test.js +33 -0
- package/test/WebSocket/Server/route.test.js +37 -0
- package/test/Client.test.js +0 -197
- package/test/Config.test.js +0 -119
- package/test/Database/ConnectionFactory.test.js +0 -80
- package/test/Lang.test.js +0 -92
- package/test/Migration.test.js +0 -943
- package/test/Odac.test.js +0 -88
- package/test/View/EarlyHints.test.js +0 -282
- 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
|
-
//
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
title: '
|
|
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
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
|
-
|
|
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
|
}
|
|
@@ -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')
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
60
|
+
// Phase 1: Stop schedulers so no new work is queued
|
|
61
|
+
Odac.Route.stopCron()
|
|
62
|
+
Odac.Storage.stopSessionGC()
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|