odac 1.4.1 → 1.4.3

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 (77) hide show
  1. package/.agent/rules/memory.md +5 -0
  2. package/.releaserc.js +9 -2
  3. package/CHANGELOG.md +64 -0
  4. package/README.md +1 -1
  5. package/bin/odac.js +3 -2
  6. package/client/odac.js +124 -28
  7. package/docs/ai/skills/backend/database.md +19 -0
  8. package/docs/ai/skills/backend/forms.md +107 -13
  9. package/docs/ai/skills/backend/migrations.md +8 -2
  10. package/docs/ai/skills/backend/validation.md +132 -32
  11. package/docs/ai/skills/frontend/forms.md +43 -15
  12. package/docs/backend/08-database/02-basics.md +49 -9
  13. package/docs/backend/08-database/04-migrations.md +1 -0
  14. package/package.json +1 -1
  15. package/src/Auth.js +15 -2
  16. package/src/Database/ConnectionFactory.js +1 -0
  17. package/src/Database/Migration.js +26 -1
  18. package/src/Database/nanoid.js +30 -0
  19. package/src/Database.js +122 -11
  20. package/src/Ipc.js +37 -0
  21. package/src/Odac.js +1 -1
  22. package/src/Route/Cron.js +11 -0
  23. package/src/Route.js +49 -30
  24. package/src/Server.js +77 -23
  25. package/src/Storage.js +15 -1
  26. package/src/Validator.js +22 -20
  27. package/test/{Auth.test.js → Auth/check.test.js} +91 -5
  28. package/test/Client/data.test.js +91 -0
  29. package/test/Client/get.test.js +90 -0
  30. package/test/Client/storage.test.js +87 -0
  31. package/test/Client/token.test.js +82 -0
  32. package/test/Client/ws.test.js +118 -0
  33. package/test/Config/deepMerge.test.js +14 -0
  34. package/test/Config/init.test.js +66 -0
  35. package/test/Config/interpolate.test.js +35 -0
  36. package/test/Database/ConnectionFactory/buildConnectionConfig.test.js +13 -0
  37. package/test/Database/ConnectionFactory/buildConnections.test.js +31 -0
  38. package/test/Database/ConnectionFactory/resolveClient.test.js +12 -0
  39. package/test/Database/Migration/migrate_column.test.js +52 -0
  40. package/test/Database/Migration/migrate_files.test.js +70 -0
  41. package/test/Database/Migration/migrate_index.test.js +89 -0
  42. package/test/Database/Migration/migrate_nanoid.test.js +160 -0
  43. package/test/Database/Migration/migrate_seed.test.js +77 -0
  44. package/test/Database/Migration/migrate_table.test.js +88 -0
  45. package/test/Database/Migration/rollback.test.js +61 -0
  46. package/test/Database/Migration/snapshot.test.js +38 -0
  47. package/test/Database/Migration/status.test.js +41 -0
  48. package/test/Database/autoNanoid.test.js +215 -0
  49. package/test/Database/nanoid.test.js +19 -0
  50. package/test/Lang/constructor.test.js +25 -0
  51. package/test/Lang/get.test.js +65 -0
  52. package/test/Lang/set.test.js +49 -0
  53. package/test/Odac/init.test.js +42 -0
  54. package/test/Odac/instance.test.js +58 -0
  55. package/test/Route/{Middleware.test.js → Middleware/chaining.test.js} +5 -29
  56. package/test/Route/Middleware/use.test.js +35 -0
  57. package/test/{Route.test.js → Route/check.test.js} +100 -50
  58. package/test/Route/set.test.js +52 -0
  59. package/test/Route/ws.test.js +23 -0
  60. package/test/View/EarlyHints/cache.test.js +32 -0
  61. package/test/View/EarlyHints/extractFromHtml.test.js +143 -0
  62. package/test/View/EarlyHints/formatLinkHeader.test.js +33 -0
  63. package/test/View/EarlyHints/send.test.js +99 -0
  64. package/test/View/{Form.test.js → Form/generateFieldHtml.test.js} +2 -2
  65. package/test/View/constructor.test.js +22 -0
  66. package/test/View/print.test.js +19 -0
  67. package/test/WebSocket/Client/limits.test.js +55 -0
  68. package/test/WebSocket/Server/broadcast.test.js +33 -0
  69. package/test/WebSocket/Server/route.test.js +37 -0
  70. package/test/Client.test.js +0 -197
  71. package/test/Config.test.js +0 -119
  72. package/test/Database/ConnectionFactory.test.js +0 -80
  73. package/test/Lang.test.js +0 -92
  74. package/test/Migration.test.js +0 -943
  75. package/test/Odac.test.js +0 -88
  76. package/test/View/EarlyHints.test.js +0 -282
  77. package/test/WebSocket.test.js +0 -238
@@ -1,60 +1,160 @@
1
1
  ---
2
2
  name: backend-validation-skill
3
- description: Fluent ODAC validation strategies for input hardening, brute-force protection, and standardized error responses.
3
+ description: Detailed ODAC Validator usage for request validation, security checks, brute-force protection, and consistent API responses.
4
4
  metadata:
5
5
  tags: backend, validation, fluent-api, input-security, brute-force, error-handling
6
6
  ---
7
7
 
8
8
  # Backend Validation Skill
9
9
 
10
- The `Validator` service provides a fluent, chainable API for securing user input and enforcing business rules.
11
-
12
- ## Architectural Approach
13
- Validation should happen as early as possible in the request lifecycle. The `Validator` service handles automatic error formatting and frontend integration.
10
+ ODAC validation should be centralized with the fluent `Validator` API and returned in framework-standard result format.
14
11
 
15
12
  ## Core Rules
16
- 1. **Chaining**: Use the fluent API: `.post(key).check(rules).message(msg)`.
17
- 2. **Brute Force**: Protect sensitive endpoints with `.brute(attempts)`.
18
- 3. **Automatic Errors**: Use `await validator.error()` to check status and `await validator.result()` to return standardized JSON.
19
- 4. **Inverse Rules**: Use `!` to invert any rule (e.g., `!required`).
13
+ 1. **Create validator per request**: Use `const validator = Odac.validator()`.
14
+ 2. **Fail fast**: Run all checks, then immediately return on `await validator.error()`.
15
+ 3. **Field-first messages**: Assign a specific `.message(...)` per check chain.
16
+ 4. **Use standard result shape**: Return `await validator.result('Validation failed')` for errors.
17
+ 5. **Protect sensitive flows**: Add `await validator.brute(n)` on login/reset/auth endpoints.
18
+
19
+ ## Minimal Flow
20
+ ```javascript
21
+ module.exports = async Odac => {
22
+ const validator = Odac.validator()
23
+
24
+ validator.post('email').check('required|email').message('Valid email required')
25
+ validator.post('password').check('required|minlen:8').message('Password must be at least 8 characters')
26
+
27
+ if (await validator.error()) {
28
+ await validator.brute(5)
29
+ return await validator.result('Validation failed')
30
+ }
31
+
32
+ return await validator.success({ok: true})
33
+ }
34
+ ```
35
+
36
+ ## API Surface
37
+ - `post(key)`: Validate POST payload field.
38
+ - `get(key)`: Validate querystring field.
39
+ - `var(name, value)`: Validate computed/custom value.
40
+ - `file(name)`: Validate uploaded file object.
41
+ - `check(rules | boolean)`: Apply pipe rules or direct boolean validation.
42
+ - `message(text)`: Set message for the latest check on current field.
43
+ - `error()`: Runs validation and returns `true` if any error exists.
44
+ - `result(message?, data?)`: Returns ODAC-standard response object.
45
+ - `success(dataOrMessage?)`: Convenience wrapper for success payload.
46
+ - `brute(maxAttempts = 5)`: Tracks failed attempts per hour/page/ip.
47
+
48
+ ## Rule Catalog
49
+
50
+ ### Type & format rules
51
+ - `required`, `accepted`
52
+ - `numeric`, `float`
53
+ - `alpha`, `alphaspace`, `alphanumeric`, `alphanumericspace`, `username`
54
+ - `email`, `ip`, `mac`, `domain`, `url`
55
+ - `array`, `date`, `xss`
56
+
57
+ ### Length & value rules
58
+ - `len:X`, `minlen:X`, `maxlen:X`
59
+ - `min:X`, `max:X`
60
+ - `equal:value`, `not:value`
61
+ - `same:field`, `different:field`
62
+
63
+ ### String/date matching rules
64
+ - `in:substring`, `notin:substring`
65
+ - `regex:pattern`
66
+ - `mindate:YYYY-MM-DD`, `maxdate:YYYY-MM-DD`
67
+
68
+ ### Auth/security rules
69
+ - `usercheck`: Must be authenticated.
70
+ - `user:field`: Input must match authenticated user field.
71
+ - `disposable`: Email must be disposable.
72
+ - `!disposable`: Email must not be disposable.
73
+
74
+ ### Inverse rules
75
+ - Prefix any rule with `!` to invert: `!required`, `!email`, `!equal:admin`.
20
76
 
21
77
  ## Reference Patterns
22
78
 
23
- ### 1. Standard Validation Chaining
79
+ ### 1) Multi-check per field with specific errors
24
80
  ```javascript
25
- module.exports = async function (Odac) {
26
- const validator = Odac.Validator;
81
+ module.exports = async Odac => {
82
+ const validator = Odac.validator()
27
83
 
28
84
  validator
29
- .post('email').check('required|email').message('Valid email required')
30
- .post('password').check('required|minlen:8').message('Password too short');
85
+ .post('password')
86
+ .check('required').message('Password is required')
87
+ .check('minlen:8').message('Minimum 8 characters')
88
+ .check('regex:[A-Z]').message('At least one uppercase letter')
89
+ .check('regex:[0-9]').message('At least one number')
31
90
 
32
91
  if (await validator.error()) {
33
- return validator.result('Please fix input errors');
92
+ return await validator.result('Please fix input errors')
34
93
  }
35
94
 
36
- // Proceed with validated data
37
- return validator.success('Success');
38
- };
95
+ return await validator.success('Success')
96
+ }
97
+ ```
98
+
99
+ ### 2) GET + POST + VAR together
100
+ ```javascript
101
+ module.exports = async Odac => {
102
+ const validator = Odac.validator()
103
+ const plan = await Odac.request('plan')
104
+
105
+ validator.get('page').check('numeric|min:1').message('Invalid page')
106
+ validator.post('email').check('required|email|!disposable').message('Corporate email required')
107
+ validator.var('plan', plan).check('in:pro').message('Only pro plan is allowed')
108
+
109
+ if (await validator.error()) return await validator.result('Validation failed')
110
+ return await validator.success({ok: true})
111
+ }
39
112
  ```
40
113
 
41
- ### 2. Custom Variable and Security Validation
114
+ ### 3) Boolean check for business rules
42
115
  ```javascript
43
- validator.var('age', userAge).check('numeric|min:18').message('Must be 18+');
116
+ module.exports = async Odac => {
117
+ const validator = Odac.validator()
118
+ const canPublish = await somePermissionCheck(Odac)
119
+
120
+ validator.post('title').check('required').message('Title required')
121
+ validator.var('permission', null).check(canPublish).message('No publish permission')
122
+
123
+ if (await validator.error()) return await validator.result('Validation failed')
124
+ return await validator.success('Published')
125
+ }
126
+ ```
127
+
128
+ ### 4) Brute-force on auth endpoint
129
+ ```javascript
130
+ module.exports = async Odac => {
131
+ const validator = Odac.validator()
132
+
133
+ validator.post('email').check('required|email').message('Email required')
134
+ validator.post('password').check('required').message('Password required')
135
+
136
+ if (await validator.error()) {
137
+ await validator.brute(5)
138
+ return await validator.result('Login failed')
139
+ }
44
140
 
45
- // Security checks
46
- validator.post('bio').check('xss').message('Malicious HTML detected');
47
- validator.var('auth', null).check('usercheck').message('Authentication required');
141
+ return await validator.success('OK')
142
+ }
48
143
  ```
49
144
 
50
- ### 3. Common Rules Reference
51
- - `required`, `email`, `numeric`, `username`, `url`, `ip`, `json`.
52
- - `len:X`, `minlen:X`, `maxlen:X`.
53
- - `mindate:YYYY-MM-DD`, `maxdate:YYYY-MM-DD`.
54
- - `regex:pattern`, `same:field`, `different:field`.
55
- - `!disposable`: Blocks temporary email providers.
145
+ ## Response Contract
146
+ - **Success**:
147
+ - `result.success: true`
148
+ - optional `result.message`
149
+ - optional `data`
150
+ - **Failure**:
151
+ - `result.success: false`
152
+ - `errors.{field}` map
153
+ - global errors may use `errors._odac_form`
56
154
 
57
155
  ## Best Practices
58
- - **Specific Messages**: Provide helpful error messages that guide the user.
59
- - **Security First**: Use the `xss` rule for any user-generated content that will be rendered later.
60
- - **Fail Fast**: Return the validation result immediately if `validator.error()` is true.
156
+ - Keep validation at route/controller entry; do not defer to deep service layers.
157
+ - Use separate `check()` calls when you need rule-specific messages.
158
+ - Prefer `var()` for derived values instead of re-reading mutable request state.
159
+ - Use `xss` for text fields that can later be rendered in HTML.
160
+ - Always combine auth endpoints with `brute()` to reduce credential-stuffing risk.
@@ -1,28 +1,56 @@
1
1
  ---
2
2
  name: frontend-forms-api-skill
3
- description: AJAX form and API request patterns in odac.js for interactive UX and predictable frontend data flows.
3
+ description: odac.js form submission patterns for parser-generated ODAC forms and predictable AJAX request handling.
4
4
  metadata:
5
- tags: frontend, forms, ajax, api-requests, odac-get, odac-post
5
+ tags: frontend, forms, ajax, odac-form, register, login, magic-login
6
6
  ---
7
7
 
8
8
  # Frontend Forms & API Skill
9
9
 
10
- Handling AJAX form submissions and API requests.
10
+ Handling ODAC AJAX form submissions generated from server-side form parsing.
11
11
 
12
12
  ## Rules
13
- 1. **Forms**: Use `odac.form('#id', callback)` for AJAX submission.
14
- 2. **Requests**: Use `odac.get()` and `odac.post()` for manual requests.
15
- 3. **Realtime**: Handle WebSocket events using Hub structures in `Odac.action()`.
13
+ 1. **Bind by form selector**: Use `Odac.form({ form: 'selector' }, callback)` or short form `Odac.form('selector', callback)`.
14
+ 2. **Leverage parser-generated forms**: `odac-register`, `odac-login`, `odac-magic-login`, and `odac-custom-form` are all auto-bound on page load and after every AJAX navigation.
15
+ 3. **Expect JSON result shape**: Handle `result.success`, `result.message`, `result.redirect`, and `errors` in callback.
16
+ 4. **Message/clear control**: Use `messages` and `clear` options for UX behavior.
16
17
 
17
18
  ## Patterns
18
19
  ```javascript
19
- // Form with automatic validation feedback
20
- odac.form('#my-form', (res) => {
21
- if(res.success) odac.visit('/done');
22
- });
23
-
24
- // Simple API Check
25
- odac.get('/api/status', (data) => {
26
- console.log('Status:', data);
27
- });
20
+ // 1) Bind a parsed custom form
21
+ Odac.form('form[data-odac-form]')
22
+
23
+ // 2) Bind parsed register/login forms explicitly (optional)
24
+ Odac.form('form[data-odac-register]')
25
+ Odac.form('form[data-odac-login]')
26
+
27
+ // 3) Bind parsed magic-login form manually
28
+ Odac.form('form[data-odac-magic-login]', response => {
29
+ if (response?.result?.success && !response.result.redirect) {
30
+ const info = document.querySelector('[data-status]')
31
+ if (info) info.textContent = response.result.message
32
+ }
33
+ })
34
+
35
+ // 4) Advanced options: disable auto clear and hide success messages
36
+ Odac.form(
37
+ {form: 'form[data-odac-form]', clear: false, messages: ['error']},
38
+ response => {
39
+ if (response?.result?.success && response.result.redirect) {
40
+ window.location.href = response.result.redirect
41
+ }
42
+ }
43
+ )
44
+
45
+ // 5) Manual GET request helper
46
+ Odac.get('/api/status', data => {
47
+ const status = document.querySelector('[data-api-status]')
48
+ if (status) status.textContent = String(data?.status ?? '')
49
+ })
28
50
  ```
51
+
52
+ ## Response Handling Contract
53
+ - **Success**: `response.result.success === true`
54
+ - **Redirect**: `response.result.redirect` exists when server wants navigation
55
+ - **Form errors**: `response.errors.{fieldName}` maps to `odac-form-error="fieldName"`
56
+ - **Global form error**: `response.errors._odac_form`
@@ -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.3",
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