odac 1.4.11 โ†’ 1.4.12

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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ### ๐Ÿ“š Documentation
2
+
3
+ - refactor database configuration to a unified structure and document multi-connection support
4
+
5
+ ### ๐Ÿ› ๏ธ Fixes & Improvements
6
+
7
+ - **mail:** add line wrapping for email content to comply with SMTP standards
8
+ - **mail:** use 990-char wrap for HTML, 76 for text; trim migration defaults
9
+ - **route:** hot-reload cron fix
10
+
11
+
12
+
13
+ ---
14
+
15
+ Powered by [โšก ODAC](https://odac.run)
16
+
1
17
  ### โœจ What's New
2
18
 
3
19
  - add esbuild-powered JS/TS frontend pipeline
@@ -30,10 +30,30 @@ const apiKey = Odac.env('API_KEY');
30
30
  ### 2. odac.json Structure
31
31
  ```json
32
32
  {
33
- "mysql": {
33
+ "database": {
34
+ "type": "mysql",
34
35
  "host": "${DB_HOST}",
35
36
  "password": "${DB_PASSWORD}"
36
37
  },
37
38
  "debug": true
38
39
  }
39
40
  ```
41
+
42
+ ### 3. Multiple Databases
43
+ ```json
44
+ {
45
+ "database": {
46
+ "default": {
47
+ "type": "mysql",
48
+ "database": "main_app"
49
+ },
50
+ "analytics": {
51
+ "type": "postgres",
52
+ "host": "analytics.local",
53
+ "database": "events"
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Access via: `Odac.DB.analytics.tableName`
@@ -16,6 +16,46 @@ High-performance database operations using the ODAC Query Builder, Read-Through
16
16
  4. **Read Caching**: Use `cache()` for frequently-read, rarely-changed data.
17
17
  5. **Write Coalescing**: Use `buffer` for high-frequency writes to avoid DB saturation.
18
18
 
19
+ ## Database Configuration (`odac.json`)
20
+
21
+ ODAC connects to databases automatically based on the `database` key in `odac.json`.
22
+
23
+ ### 1. Single Connection
24
+ ```json
25
+ {
26
+ "database": {
27
+ "type": "mysql",
28
+ "host": "${DB_HOST}",
29
+ "user": "${DB_USER}",
30
+ "password": "${DB_PASSWORD}",
31
+ "database": "myapp"
32
+ }
33
+ }
34
+ ```
35
+
36
+ ### 2. Multiple Connections
37
+ Define named objects. The one named `default` (or the first one) is used by default.
38
+ ```json
39
+ {
40
+ "database": {
41
+ "default": {
42
+ "type": "mysql",
43
+ "database": "app_db"
44
+ },
45
+ "analytics": {
46
+ "type": "postgres",
47
+ "host": "remote-stats.db",
48
+ "database": "events"
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Access named connections via: `Odac.DB.analytics.tableName`
55
+
56
+ ### 3. Environment Variables
57
+ Always use `${VAR_NAME}` for sensitive credentials. Map them in `.env`.
58
+
19
59
  ## Query Builder Patterns
20
60
  ```javascript
21
61
  const user = await Odac.DB.users
@@ -12,7 +12,8 @@ The main configuration file located in your website's root directory. This file
12
12
  "request": {
13
13
  "timeout": 30000
14
14
  },
15
- "mysql": {
15
+ "database": {
16
+ "type": "mysql",
16
17
  "host": "localhost",
17
18
  "user": "root",
18
19
  "password": "secret123",
@@ -38,7 +39,8 @@ Perfect for development or non-sensitive settings:
38
39
 
39
40
  ```json
40
41
  {
41
- "mysql": {
42
+ "database": {
43
+ "type": "mysql",
42
44
  "host": "localhost",
43
45
  "password": "dev123"
44
46
  }
@@ -50,7 +52,8 @@ Use `${VARIABLE}` syntax in `odac.json` to reference `.env` values:
50
52
 
51
53
  ```json
52
54
  {
53
- "mysql": {
55
+ "database": {
56
+ "type": "mysql",
54
57
  "host": "${MYSQL_HOST}",
55
58
  "password": "${MYSQL_PASSWORD}"
56
59
  }
@@ -68,7 +71,8 @@ Combine both methods - use direct values for non-sensitive data and environment
68
71
 
69
72
  ```json
70
73
  {
71
- "mysql": {
74
+ "database": {
75
+ "type": "mysql",
72
76
  "host": "localhost",
73
77
  "user": "root",
74
78
  "password": "${MYSQL_PASSWORD}",
@@ -88,7 +92,7 @@ You can access configuration values in three ways:
88
92
 
89
93
  ```javascript
90
94
  // 1. From Odac.Config (recommended for structured config)
91
- const dbHost = Odac.Config.mysql.host
95
+ const dbHost = Odac.Config.database.host
92
96
 
93
97
  // 2. Using Odac.env() helper
94
98
  const apiKey = Odac.env('API_KEY')
@@ -130,6 +134,7 @@ const nodeEnv = process.env.NODE_ENV
130
134
  ```json
131
135
  {
132
136
  "database": {
137
+ "type": "mysql",
133
138
  "host": "localhost",
134
139
  "user": "root",
135
140
  "password": "${MYSQL_PASSWORD}",
@@ -195,7 +200,8 @@ See individual documentation sections for detailed configuration options.
195
200
  "request": {
196
201
  "timeout": 30000
197
202
  },
198
- "mysql": {
203
+ "database": {
204
+ "type": "mysql",
199
205
  "host": "${MYSQL_HOST}",
200
206
  "user": "${MYSQL_USER}",
201
207
  "password": "${MYSQL_PASSWORD}",
@@ -1,12 +1,13 @@
1
1
  ## ๐Ÿ”Œ Database Connection
2
2
 
3
- When you add a `mysql` object to your `odac.json`, the system will automatically connect to your MySQL database. No separate connection setup is needed in your code.
3
+ When you add a `database` object to your `odac.json`, the system will automatically connect to your database. No separate connection setup is needed in your code.
4
4
 
5
5
  ### Basic Configuration
6
6
 
7
7
  ```json
8
8
  {
9
- "mysql": {
9
+ "database": {
10
+ "type": "mysql",
10
11
  "host": "localhost",
11
12
  "user": "your_user",
12
13
  "password": "your_password",
@@ -24,7 +25,8 @@ For better security, especially in production, you can use environment variables
24
25
  **odac.json:**
25
26
  ```json
26
27
  {
27
- "mysql": {
28
+ "database": {
29
+ "type": "mysql",
28
30
  "host": "${MYSQL_HOST}",
29
31
  "user": "${MYSQL_USER}",
30
32
  "password": "${MYSQL_PASSWORD}",
@@ -48,7 +50,8 @@ You can also mix direct values with environment variables:
48
50
 
49
51
  ```json
50
52
  {
51
- "mysql": {
53
+ "database": {
54
+ "type": "mysql",
52
55
  "host": "localhost",
53
56
  "user": "root",
54
57
  "password": "${MYSQL_PASSWORD}",
@@ -58,3 +61,37 @@ You can also mix direct values with environment variables:
58
61
  ```
59
62
 
60
63
  This way, non-sensitive values are directly in the config while passwords remain in the `.env` file.
64
+
65
+ ### Multiple Database Connections
66
+
67
+ ODAC supports multiple simultaneous database connections. You can define them as named objects within the `database` configuration:
68
+
69
+ ```json
70
+ {
71
+ "database": {
72
+ "default": {
73
+ "type": "mysql",
74
+ "host": "localhost",
75
+ "database": "app_db"
76
+ },
77
+ "analytics": {
78
+ "type": "postgres",
79
+ "host": "remote-stats.db",
80
+ "database": "events"
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ #### Usage in Code
87
+
88
+ To use a specific connection, access it by its name via `Odac.DB`:
89
+
90
+ ```javascript
91
+ // Uses 'default' connection
92
+ const users = await Odac.DB.users.select('*')
93
+
94
+ // Uses 'analytics' connection
95
+ const events = await Odac.DB.analytics.pageviews.select('*')
96
+ ```
97
+
@@ -43,7 +43,8 @@ Reference environment variables using `${VARIABLE_NAME}` syntax:
43
43
 
44
44
  ```json
45
45
  {
46
- "mysql": {
46
+ "database": {
47
+ "type": "mysql",
47
48
  "host": "${MYSQL_HOST}",
48
49
  "user": "${MYSQL_USER}",
49
50
  "password": "${MYSQL_PASSWORD}",
@@ -95,7 +96,7 @@ module.exports = function() {
95
96
 
96
97
  ```javascript
97
98
  module.exports = function() {
98
- const dbHost = Odac.Config.mysql.host
99
+ const dbHost = Odac.Config.database.host
99
100
  const apiKey = Odac.Config.api.stripe.key
100
101
  }
101
102
  ```
@@ -66,6 +66,17 @@ You can configure multiple database connections. The connection named `default`
66
66
  }
67
67
  ```
68
68
 
69
+ To use a named connection in your code, simply access it through `Odac.DB`:
70
+
71
+ ```javascript
72
+ // Primary database (default)
73
+ const users = await Odac.DB.users.where('active', true)
74
+
75
+ // Analytics database
76
+ const logs = await Odac.DB.analytics.events.insert({ type: 'login' })
77
+ ```
78
+
79
+
69
80
  ---
70
81
 
71
82
  ## Environment Variables
@@ -8,7 +8,8 @@ The `<odac:register>` component provides a zero-configuration way to create secu
8
8
 
9
9
  ```json
10
10
  {
11
- "mysql": {
11
+ "database": {
12
+ "type": "mysql",
12
13
  "host": "localhost",
13
14
  "user": "root",
14
15
  "password": "",
@@ -54,7 +55,8 @@ If you want to customize table names or primary key:
54
55
 
55
56
  ```json
56
57
  {
57
- "mysql": {
58
+ "database": {
59
+ "type": "mysql",
58
60
  "host": "localhost",
59
61
  "user": "root",
60
62
  "password": "",
@@ -468,11 +470,12 @@ This provides instant feedback to users before form submission.
468
470
 
469
471
  ### Required Configuration
470
472
 
471
- Only MySQL configuration is required:
473
+ Only database configuration is required:
472
474
 
473
475
  ```json
474
476
  {
475
- "mysql": {
477
+ "database": {
478
+ "type": "mysql",
476
479
  "host": "localhost",
477
480
  "user": "root",
478
481
  "password": "",
@@ -8,7 +8,8 @@ The `<odac:login>` component provides a zero-configuration way to create secure
8
8
 
9
9
  ```json
10
10
  {
11
- "mysql": {
11
+ "database": {
12
+ "type": "mysql",
12
13
  "host": "localhost",
13
14
  "user": "root",
14
15
  "password": "",
@@ -47,7 +48,8 @@ If you want to customize table names or primary key:
47
48
 
48
49
  ```json
49
50
  {
50
- "mysql": {
51
+ "database": {
52
+ "type": "mysql",
51
53
  "host": "localhost",
52
54
  "user": "root",
53
55
  "password": "",
@@ -428,11 +430,12 @@ input._odac_error {
428
430
 
429
431
  ### Required Configuration
430
432
 
431
- Only MySQL configuration is required:
433
+ Only database configuration is required:
432
434
 
433
435
  ```json
434
436
  {
435
- "mysql": {
437
+ "database": {
438
+ "type": "mysql",
436
439
  "host": "localhost",
437
440
  "user": "root",
438
441
  "password": "",
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.11",
10
+ "version": "1.4.12",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
@@ -681,7 +681,7 @@ class Migration {
681
681
  */
682
682
  async _createTable(knex, tableName, schema) {
683
683
  await knex.schema.createTable(tableName, table => {
684
- this._buildColumns(table, schema.columns)
684
+ this._buildColumns(knex, table, schema.columns)
685
685
  this._buildIndexes(table, schema.indexes)
686
686
  })
687
687
  }
@@ -716,13 +716,13 @@ class Migration {
716
716
  for (const op of batchOps) {
717
717
  switch (op.type) {
718
718
  case 'add_column':
719
- this._addColumn(table, op.column, op.definition)
719
+ this._addColumn(knex, table, op.column, op.definition)
720
720
  break
721
721
  case 'drop_column':
722
722
  table.dropColumn(op.column)
723
723
  break
724
724
  case 'alter_column':
725
- this._alterColumn(table, op.column, op.definition, op.currentNullable)
725
+ this._alterColumn(knex, table, op.column, op.definition, op.currentNullable)
726
726
  break
727
727
  }
728
728
  }
@@ -739,8 +739,9 @@ class Migration {
739
739
 
740
740
  // Apply default value change if specified
741
741
  if (op.definition.default !== undefined) {
742
- if (op.definition.default === 'now()') {
743
- await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT now()`, [tableName, op.column])
742
+ const lower = String(op.definition.default).toLowerCase().trim()
743
+ if (lower === 'now()' || lower === 'current_timestamp' || lower === 'current_timestamp()') {
744
+ await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT ${lower}`, [tableName, op.column])
744
745
  } else {
745
746
  await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT ?`, [tableName, op.column, op.definition.default])
746
747
  }
@@ -915,7 +916,27 @@ class Migration {
915
916
  * @param {object} table - Knex TableBuilder instance
916
917
  * @param {object} columns - Column definition map
917
918
  */
918
- _buildColumns(table, columns) {
919
+ /**
920
+ * Resolves a column default value, wrapping special SQL keywords in knex.raw().
921
+ * Why: Knex.defaultTo() quotes string values by default. For keywords like
922
+ * CURRENT_TIMESTAMP, this results in 'CURRENT_TIMESTAMP' which MySQL rejects.
923
+ * Wrapping in knex.raw() ensures the keyword is emitted without quotes.
924
+ * @param {object} knex - Knex instance
925
+ * @param {*} value - Raw default value from schema
926
+ * @returns {*} Resolved value (possibly knex.raw)
927
+ */
928
+ _resolveDefault(knex, value) {
929
+ if (typeof value !== 'string') return value
930
+
931
+ const lower = value.toLowerCase().trim()
932
+ if (lower === 'current_timestamp' || lower === 'current_timestamp()' || lower === 'now()') {
933
+ return knex.raw(value)
934
+ }
935
+
936
+ return value
937
+ }
938
+
939
+ _buildColumns(knex, table, columns) {
919
940
  if (!columns) return
920
941
 
921
942
  for (const [colName, def] of Object.entries(columns)) {
@@ -930,7 +951,7 @@ class Migration {
930
951
  if (def.nullable === false) col.notNullable()
931
952
  else if (def.nullable === true) col.nullable()
932
953
 
933
- if (def.default !== undefined) col.defaultTo(def.default)
954
+ if (def.default !== undefined) col.defaultTo(this._resolveDefault(knex, def.default))
934
955
  if (def.unsigned) col.unsigned()
935
956
  // Column-level unique is handled via _normalizeSchema โ†’ _buildIndexes.
936
957
  // Applying it here as well would create duplicate constraints.
@@ -1019,14 +1040,14 @@ class Migration {
1019
1040
  * @param {string} colName - Column name
1020
1041
  * @param {object} def - Column definition
1021
1042
  */
1022
- _addColumn(table, colName, def) {
1043
+ _addColumn(knex, table, colName, def) {
1023
1044
  const col = this._createColumnBuilder(table, colName, def)
1024
1045
  if (!col) return
1025
1046
 
1026
1047
  if (def.nullable === false) col.notNullable()
1027
1048
  else col.nullable()
1028
1049
 
1029
- if (def.default !== undefined) col.defaultTo(def.default)
1050
+ if (def.default !== undefined) col.defaultTo(this._resolveDefault(knex, def.default))
1030
1051
  if (def.unsigned) col.unsigned()
1031
1052
  if (def.references) col.references(def.references.column).inTable(def.references.table)
1032
1053
  if (def.onDelete) col.onDelete(def.onDelete)
@@ -1039,7 +1060,7 @@ class Migration {
1039
1060
  * @param {string} colName - Column name
1040
1061
  * @param {object} def - Column definition
1041
1062
  */
1042
- _alterColumn(table, colName, def, currentNullable) {
1063
+ _alterColumn(knex, table, colName, def, currentNullable) {
1043
1064
  const col = this._createColumnBuilder(table, colName, def)
1044
1065
  if (!col) return
1045
1066
 
@@ -1052,7 +1073,7 @@ class Migration {
1052
1073
  else if (currentNullable === false) col.notNullable()
1053
1074
  else if (currentNullable === true) col.nullable()
1054
1075
 
1055
- if (def.default !== undefined) col.defaultTo(def.default)
1076
+ if (def.default !== undefined) col.defaultTo(this._resolveDefault(knex, def.default))
1056
1077
 
1057
1078
  col.alter()
1058
1079
  }
package/src/Mail.js CHANGED
@@ -317,6 +317,62 @@ class Mail {
317
317
  return '=?UTF-8?B?' + Buffer.from(text).toString('base64') + '?='
318
318
  }
319
319
 
320
+ #wrapLines(content, limit = 76) {
321
+ if (!content) return ''
322
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
323
+ const out = []
324
+ for (const line of normalized.split('\n')) {
325
+ if (Buffer.byteLength(line, 'utf8') <= limit) {
326
+ out.push(line)
327
+ continue
328
+ }
329
+ let buf = ''
330
+ let bufBytes = 0
331
+ const flush = () => {
332
+ if (buf.length) out.push(buf)
333
+ buf = ''
334
+ bufBytes = 0
335
+ }
336
+ for (const word of line.split(/(\s+)/)) {
337
+ if (!word) continue
338
+ const wordBytes = Buffer.byteLength(word, 'utf8')
339
+ if (bufBytes + wordBytes <= limit) {
340
+ buf += word
341
+ bufBytes += wordBytes
342
+ continue
343
+ }
344
+ if (buf.length && /^\s+$/.test(word)) {
345
+ flush()
346
+ continue
347
+ }
348
+ if (buf.length) flush()
349
+ if (wordBytes <= limit) {
350
+ buf = word
351
+ bufBytes = wordBytes
352
+ continue
353
+ }
354
+ let chunk = ''
355
+ let chunkBytes = 0
356
+ for (const ch of word) {
357
+ const chBytes = Buffer.byteLength(ch, 'utf8')
358
+ if (chunkBytes + chBytes > limit) {
359
+ out.push(chunk)
360
+ chunk = ''
361
+ chunkBytes = 0
362
+ }
363
+ chunk += ch
364
+ chunkBytes += chBytes
365
+ }
366
+ if (chunk.length) {
367
+ buf = chunk
368
+ bufBytes = chunkBytes
369
+ }
370
+ }
371
+ flush()
372
+ }
373
+ return out.join('\r\n')
374
+ }
375
+
320
376
  #stripHtml(html) {
321
377
  if (!html) return ''
322
378
 
@@ -376,6 +432,11 @@ class Mail {
376
432
  }
377
433
  }
378
434
 
435
+ // RFC 5321 ยง4.5.3.1.6: SMTP lines must be โ‰ค1000 octets including CRLF.
436
+ // Wrap to 990 chars for HTML and 76 for text to prevent SMTP rejection.
437
+ htmlContent = this.#wrapLines(htmlContent, 990)
438
+ textContent = this.#wrapLines(textContent)
439
+
379
440
  if (!this.#header['From']) this.#header['From'] = `${this.#encode(this.#from.name)} <${this.#from.email}>`
380
441
  if (!this.#header['To']) {
381
442
  const t = this.#to.value[0]
package/src/Route/Cron.js CHANGED
@@ -104,6 +104,15 @@ class Cron {
104
104
  }
105
105
  }
106
106
 
107
+ /**
108
+ * Removes all cron jobs registered from a given route file.
109
+ * Called before hot-reloading a route file to prevent duplicate cron registrations.
110
+ */
111
+ clear(route) {
112
+ if (!route) return
113
+ this.#jobs = this.#jobs.filter(job => job.route !== route)
114
+ }
115
+
107
116
  job(controller) {
108
117
  let path
109
118
  if (typeof controller !== 'function') {
@@ -114,6 +123,7 @@ class Cron {
114
123
  }
115
124
  }
116
125
  this.#jobs.push({
126
+ route: global.Odac?.Route?.buff || null,
117
127
  controller: typeof controller === 'function' ? null : controller,
118
128
  lastRun: null,
119
129
  condition: [],
package/src/Route.js CHANGED
@@ -452,6 +452,7 @@ class Route {
452
452
 
453
453
  if (!routes2[Odac.Route.buff] || routes2[Odac.Route.buff] < mtime - 1000) {
454
454
  delete require.cache[require.resolve(filePath)]
455
+ Cron.clear(Odac.Route.buff)
455
456
  routes2[Odac.Route.buff] = mtime
456
457
  const routeModule = require(filePath)
457
458
  if (typeof routeModule === 'function') {