odac 1.4.8 → 1.4.10

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 (37) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/docs/ai/README.md +2 -1
  3. package/docs/ai/skills/SKILL.md +2 -1
  4. package/docs/ai/skills/backend/authentication.md +12 -6
  5. package/docs/ai/skills/backend/database.md +85 -5
  6. package/docs/ai/skills/backend/migrations.md +23 -0
  7. package/docs/ai/skills/backend/odac-var.md +155 -0
  8. package/docs/ai/skills/backend/utilities.md +1 -1
  9. package/docs/ai/skills/frontend/forms.md +23 -1
  10. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  11. package/docs/backend/04-routing/09-websocket.md +22 -1
  12. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  13. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  14. package/docs/backend/10-authentication/05-session-management.md +12 -3
  15. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  16. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  17. package/docs/index.json +1 -1
  18. package/package.json +1 -1
  19. package/src/Auth.js +17 -0
  20. package/src/Database/Migration.js +321 -10
  21. package/src/Database/ReadCache.js +174 -0
  22. package/src/Database/WriteBuffer.js +15 -1
  23. package/src/Database.js +78 -1
  24. package/src/Validator.js +1 -1
  25. package/src/Var.js +1 -0
  26. package/src/WebSocket.js +80 -23
  27. package/test/Database/Migration/migrate_column.test.js +311 -0
  28. package/test/Database/ReadCache/crossTable.test.js +179 -0
  29. package/test/Database/ReadCache/get.test.js +128 -0
  30. package/test/Database/ReadCache/invalidate.test.js +103 -0
  31. package/test/Database/ReadCache/proxy.test.js +184 -0
  32. package/test/Database/WriteBuffer/insert.test.js +118 -0
  33. package/test/Database/insert.test.js +98 -0
  34. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  35. package/test/WebSocket/Client/limits.test.js +10 -4
  36. package/test/WebSocket/Client/readyState.test.js +154 -0
  37. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
@@ -0,0 +1,206 @@
1
+ # Read-Through Cache
2
+
3
+ At high traffic, repeatedly querying the same data — blog posts, product listings, settings, categories — generates redundant database round-trips. A page with 10,000 daily visitors reading the same 50 posts means 10,000 identical `SELECT` queries.
4
+
5
+ ODAC's **Read-Through Cache** solves this by caching `SELECT` results in `Odac.Ipc` and serving subsequent requests from memory. The only change to your code is adding `.cache()` to the chain.
6
+
7
+ ```javascript
8
+ // Without cache — 1 DB query per request
9
+ const posts = await Odac.DB.posts.where('active', true).select('id', 'title')
10
+
11
+ // With cache — 1 DB query per TTL window, for all requests combined
12
+ const posts = await Odac.DB.posts.cache(60).where('active', true).select('id', 'title')
13
+ ```
14
+
15
+ ---
16
+
17
+ ## How It Works
18
+
19
+ **Architecture: Ipc-Backed, Driver-Agnostic**
20
+
21
+ All cached data is stored in `Odac.Ipc`. The active IPC driver determines the scaling model:
22
+
23
+ | Driver | Scope | When to use |
24
+ |---|---|---|
25
+ | `memory` (default) | Single machine — all workers share cache via Primary process | Single-server deployments |
26
+ | `redis` | Multi-machine — all servers share cache in Redis | Horizontal scaling behind a load balancer |
27
+
28
+ ```
29
+ // Memory driver (default)
30
+ Worker 1 ─┐
31
+ Worker 2 ─┼──→ Primary (Ipc memory store) ← cache HIT: O(1) return
32
+ Worker N ─┘ ← cache MISS: DB query → store → return
33
+
34
+ // Redis driver
35
+ Server A ─┐
36
+ Server B ─┼──→ Redis (Ipc state) ← shared cache across all servers
37
+ Server C ─┘
38
+ ```
39
+
40
+ **Cache Key Generation**
41
+
42
+ Each query is identified by a SHA-256 hash of its compiled SQL and bindings. Two identical queries — regardless of which worker or server generates them — always resolve to the same cache key.
43
+
44
+ ```
45
+ rc:{connection}:{table}:{sha256(sql + bindings)}
46
+ ```
47
+
48
+ **Automatic Invalidation**
49
+
50
+ Any `insert()`, `update()`, `delete()`, or `truncate()` on a table automatically purges all cached queries for that table. You never need to manually invalidate after a write — ODAC handles it.
51
+
52
+ ```javascript
53
+ // This update automatically clears all cached queries on the 'posts' table
54
+ await Odac.DB.posts.where({id: 5}).update({title: 'New Title'})
55
+ ```
56
+
57
+ **Cross-Table Invalidation (JOIN queries)**
58
+
59
+ When a cached query includes `JOIN` clauses, ODAC automatically registers the cache key in all joined tables' indexes. This means a write to any table involved in the query triggers invalidation.
60
+
61
+ ```javascript
62
+ // This query is registered in BOTH 'posts' and 'users' cache indexes
63
+ const data = await Odac.DB.posts
64
+ .cache(60)
65
+ .join('users', 'posts.user_id', '=', 'users.id')
66
+ .select('posts.title', 'users.name')
67
+
68
+ // Writing to 'users' invalidates the cached join query above — no stale data
69
+ await Odac.DB.users.where({id: 1}).update({name: 'New Name'})
70
+ ```
71
+
72
+ This works with `join()`, `leftJoin()`, `rightJoin()`, and aliased tables (`users as u`).
73
+
74
+ ---
75
+
76
+ ## Basic Usage
77
+
78
+ ### Cache with TTL
79
+
80
+ Pass a TTL (in seconds) to `.cache()`. The result is served from cache for that duration, then re-fetched from the database on the next request.
81
+
82
+ ```javascript
83
+ // Cache for 60 seconds
84
+ const posts = await Odac.DB.posts.cache(60).where('active', true).select('id', 'title')
85
+
86
+ // Cache for 5 minutes (default TTL from config)
87
+ const post = await Odac.DB.posts.cache().where({id: 5}).first()
88
+
89
+ // Cache a count
90
+ const total = await Odac.DB.posts.cache(300).where('active', true).count()
91
+ ```
92
+
93
+ ### Named Connections
94
+
95
+ ```javascript
96
+ // Default connection
97
+ const posts = await Odac.DB.posts.cache(60).select('id', 'title')
98
+
99
+ // Named connection: 'analytics'
100
+ const stats = await Odac.DB.analytics.events.cache(120).where('date', today).select()
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Manual Invalidation
106
+
107
+ ### Table-Level Clear
108
+
109
+ Purge all cached queries for a table:
110
+
111
+ ```javascript
112
+ // Via table proxy
113
+ await Odac.DB.posts.cache.clear()
114
+
115
+ // Via global accessor (useful in background jobs or service classes)
116
+ await Odac.DB.cache.clear('default', 'posts')
117
+
118
+ // Named connection
119
+ await Odac.DB.cache.clear('analytics', 'events')
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Automatic Invalidation on Write
125
+
126
+ You do not need to call `.cache.clear()` after writes. ODAC intercepts all write operations on the proxy and invalidates the table cache automatically.
127
+
128
+ | Operation | Cache invalidated? |
129
+ |---|---|
130
+ | `insert()` | ✅ Yes |
131
+ | `update()` | ✅ Yes |
132
+ | `delete()` | ✅ Yes |
133
+ | `del()` | ✅ Yes |
134
+ | `truncate()` | ✅ Yes |
135
+ | `buffer.insert()` | ❌ No — buffer writes are deferred; invalidate manually if needed |
136
+
137
+ > **Note on `buffer` + `cache`:** If you use the Write-Behind Cache (`buffer`) alongside the Read-Through Cache, the buffer's deferred writes do not trigger automatic invalidation. Call `Odac.DB.posts.cache.clear()` after a `buffer.flush()` if you need the cache to reflect the latest state immediately.
138
+
139
+ ---
140
+
141
+ ## Configuration
142
+
143
+ Add a `cache` section to your `odac.json`:
144
+
145
+ ```json
146
+ {
147
+ "cache": {
148
+ "ttl": 300,
149
+ "maxKeys": 10000
150
+ }
151
+ }
152
+ ```
153
+
154
+ | Option | Default | Description |
155
+ |---|---|---|
156
+ | `ttl` | `300` | Default cache duration in seconds when `.cache()` is called without an argument |
157
+ | `maxKeys` | `10000` | Maximum number of cached query keys per table. New entries are skipped once the limit is reached — protects against unbounded memory growth |
158
+
159
+ ---
160
+
161
+ ## Horizontal Scaling
162
+
163
+ To share cache state across multiple servers, switch the `ipc` driver to `redis`:
164
+
165
+ ```json
166
+ {
167
+ "ipc": {
168
+ "driver": "redis",
169
+ "redis": "default"
170
+ }
171
+ }
172
+ ```
173
+
174
+ With the Redis driver active, all servers share the same cache. A cache invalidation triggered by a write on Server A is immediately visible to Server B. No code changes are required in your application.
175
+
176
+ ---
177
+
178
+ ## When to Use (and Not Use)
179
+
180
+ **Use Read-Through Cache for:**
181
+ - Blog posts, articles, product listings
182
+ - Navigation menus, category trees, tag lists
183
+ - Site settings, feature flags, configuration values
184
+ - Any data that is read frequently and changes infrequently
185
+
186
+ **Do not use for:**
187
+ - Data that must always reflect the latest DB state (e.g., account balances, inventory counts)
188
+ - Queries with user-specific filters where caching would leak data across users
189
+ - Queries inside transactions where consistency is critical
190
+
191
+ > [!TIP]
192
+ > Combine with the Write-Behind Cache for maximum throughput: use `.buffer` for high-frequency writes (view counters, last-active timestamps) and `.cache()` for high-frequency reads (post listings, settings). They operate independently and complement each other.
193
+
194
+ ---
195
+
196
+ ## Guarantees
197
+
198
+ | Scenario | Behaviour |
199
+ |---|---|
200
+ | Cache MISS | DB query executes, result is stored in Ipc with TTL |
201
+ | Cache HIT | Result returned from Ipc — no DB query |
202
+ | TTL expired | Next request triggers a fresh DB query and re-caches |
203
+ | `insert/update/delete` | All cached queries for that table are purged automatically |
204
+ | Worker crash | No data loss — cache state is in Primary process (memory) or Redis |
205
+ | `maxKeys` reached | New cache entries are skipped; existing entries still served |
206
+ | Ipc not initialized | Invalidation is a no-op — safe for environments without Ipc setup |
@@ -0,0 +1,53 @@
1
+ ## 🔐 Authentication Basics
2
+
3
+ The `Odac.Auth` service is your bouncer, managing who gets in and who stays out. It handles user login sessions for you.
4
+
5
+ #### Letting a User In
6
+
7
+ `Odac.Auth.login(userId, userData)`
8
+
9
+ * `userId`: A unique ID for the user (like their database ID).
10
+ * `userData`: An object with any user info you want to remember, like their username or role.
11
+
12
+ When you call this, `Auth` creates a secure session for the user.
13
+
14
+ > **💡 Enterprise Security:** ODAC automatically handles **Token Rotation** every 15 minutes (configurable) and includes built-in **CSRF protection** for all forms. Sessions are persistent across browser restarts by default.
15
+
16
+ #### Checking the Guest List
17
+
18
+ * `await Odac.Auth.check()`: Is the current user logged in? Returns `true` or `false`.
19
+ * `Odac.Auth.user()`: Gets the full user object of the logged-in user.
20
+ * `Odac.Auth.user('id')`: Gets a specific field from the user object (e.g., their ID).
21
+ * `Odac.Auth.token()`: Gets the full auth session record from the token table.
22
+ * `Odac.Auth.token('id')`: Gets the auth session ID from the token table.
23
+
24
+ #### Showing a User Out
25
+
26
+ * `Odac.Auth.logout()`: Ends the user's session and logs them out.
27
+
28
+ #### Example: A Login Flow
29
+ ```javascript
30
+ // Controller for your login form
31
+ module.exports = async function (Odac) {
32
+ const { username, password } = Odac.Request.post
33
+
34
+ const loginSuccess = await Odac.Auth.login({ username, password })
35
+
36
+ if (loginSuccess) {
37
+ return Odac.direct('/dashboard')
38
+ } else {
39
+ return Odac.direct('/login?error=1')
40
+ }
41
+ }
42
+
43
+ // A protected dashboard page
44
+ module.exports = async function (Odac) {
45
+ if (!await Odac.Auth.check()) {
46
+ return Odac.direct('/login')
47
+ }
48
+
49
+ const username = Odac.Auth.user('username')
50
+ const authId = Odac.Auth.token('id') // Auth session ID from token table
51
+ return `Welcome back, ${username}! (Session: ${authId})`
52
+ }
53
+ ```
@@ -148,11 +148,20 @@ const isLoggedIn = await Odac.Auth.check()
148
148
 
149
149
  **Get user info:**
150
150
  ```javascript
151
- const user = Odac.Auth.user(null) // Full user object
152
- const user = Odac.Auth.user(null) // Full user object
153
- const email = Odac.Auth.user('email') // Specific field
151
+ const user = Odac.Auth.user() // Full user object
152
+ const email = Odac.Auth.user('email') // Specific user field
154
153
  ```
155
154
 
155
+ **Get auth session record:**
156
+ ```javascript
157
+ const authRecord = Odac.Auth.token() // Full token record from auth table
158
+ const authId = Odac.Auth.token('id') // Auth session ID
159
+ const sessionIp = Odac.Auth.token('ip') // IP address of the session
160
+ const sessionDate = Odac.Auth.token('date') // When the session was created
161
+ ```
162
+
163
+ > **Note:** `token()` returns `false` if no active session exists. It is populated after a successful `check()` call.
164
+
156
165
  ### Custom Session Data
157
166
 
158
167
  If you need to store your own data in the session (e.g. shopping cart ID, preferences), use the `Odac.session()` helper:
@@ -8,9 +8,6 @@
8
8
  // Create a Var instance
9
9
  const result = Odac.Var('hello world').slug()
10
10
  // Returns: 'hello-world'
11
-
12
- // Chain multiple operations
13
- const email = Odac.Var(' USER@EXAMPLE.COM ').trim().toLowerCase()
14
11
  ```
15
12
 
16
13
  ### String Validation
@@ -47,19 +44,20 @@ Odac.Var('example.com').isAny('email', 'domain') // true
47
44
  'alphaspace' // Letters and spaces
48
45
  'alphanumeric' // Letters and numbers
49
46
  'alphanumericspace' // Letters, numbers, and spaces
50
- 'bcrypt' // BCrypt hash format
47
+ 'username' // Alphanumeric only (no spaces)
48
+ 'hash' // scrypt hash format ($scrypt$)
51
49
  'date' // Valid date string
52
50
  'domain' // Valid domain name (example.com)
53
51
  'email' // Valid email address
54
52
  'float' // Floating point number
55
- 'host' // IP address
53
+ 'host' // IP address / Host
56
54
  'ip' // IP address
57
55
  'json' // Valid JSON string
58
56
  'mac' // MAC address
59
- 'md5' // MD5 hash
57
+ 'md5' // 32-char MD5 hash
60
58
  'numeric' // Numbers only
61
- 'url' // Valid URL
62
- 'emoji' // Contains emoji
59
+ 'url' // Valid URL (protocol + domain)
60
+ 'emoji' // Contains emoji characters
63
61
  'xss' // XSS-safe (no HTML tags)
64
62
  ```
65
63
 
@@ -193,21 +191,18 @@ Odac.Var('<script>alert("xss")</script>').html()
193
191
 
194
192
  ### Encryption & Hashing
195
193
 
196
- #### hash() - BCrypt password hashing
194
+ #### hash() - Secure scrypt password hashing
197
195
 
198
196
  ```javascript
199
- // Hash a password
197
+ // Hash a password using enterprise-grade scrypt
200
198
  const hashedPassword = Odac.Var('mypassword').hash()
201
- // Returns: '$2b$10$...' (BCrypt hash)
202
-
203
- // Custom salt rounds
204
- const hashedPassword = Odac.Var('mypassword').hash(12)
199
+ // Returns: '$scrypt$[salt]$[hash]'
205
200
  ```
206
201
 
207
- #### hashCheck() - Verify BCrypt hash
202
+ #### hashCheck() - Verify scrypt hash
208
203
 
209
204
  ```javascript
210
- const hashedPassword = '$2b$10$...'
205
+ const hashedPassword = '$scrypt$...'
211
206
  const isValid = Odac.Var(hashedPassword).hashCheck('mypassword')
212
207
  // Returns: true or false
213
208
  ```
@@ -498,7 +493,6 @@ module.exports = async function(Odac) {
498
493
 
499
494
  ### Notes
500
495
 
501
- - `Odac.Var()` returns the processed string value, not a Var instance (except for chaining)
502
- - Encryption uses AES-256-CBC with a fixed IV
503
- - BCrypt hashing is one-way and cannot be decrypted
496
+ - Encryption uses AES-256-CBC with a fixed IV (defined in framework core).
497
+ - scrypt hashing is one-way and computationally expensive to prevent brute-force.
504
498
  - Date formatting works with any valid JavaScript date string
@@ -200,11 +200,24 @@ odac.form({
200
200
  ### Disable Specific Messages
201
201
 
202
202
  ```javascript
203
+ // Only show error messages, suppress success messages
203
204
  odac.form({
204
205
  form: '#my-form',
205
- messages: ['error'] // Only show errors, not success
206
+ messages: ['error']
206
207
  }, function(data) {
207
- // Custom success handling
208
+ if (data.result.success) {
209
+ // Custom success handling
210
+ }
211
+ })
212
+
213
+ // Only show success messages, suppress error messages
214
+ odac.form({
215
+ form: '#my-form',
216
+ messages: ['success']
217
+ }, function(data) {
218
+ if (!data.result.success) {
219
+ // Custom error handling
220
+ }
208
221
  })
209
222
  ```
210
223
 
package/docs/index.json CHANGED
@@ -255,7 +255,7 @@
255
255
  "title": "Authentication & Security",
256
256
  "children": [
257
257
  {
258
- "file": "01-user-logins-with-authjs.md",
258
+ "file": "01-authentication-basics.md",
259
259
  "title": "User Authentication"
260
260
  },
261
261
  {
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.8",
10
+ "version": "1.4.10",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
package/src/Auth.js CHANGED
@@ -4,6 +4,7 @@ const TOKEN_ROTATION_GRACE_PERIOD_MS = 60 * 1000
4
4
  class Auth {
5
5
  #request = null
6
6
  #table = null
7
+ #token = null
7
8
  #user = null
8
9
  static #migrationCache = new Set()
9
10
 
@@ -140,6 +141,8 @@ class Auth {
140
141
  this.#user = await Odac.DB[this.#table].where(primaryKey, sql_token[0].user).first()
141
142
  if (!this.#user) return false
142
143
 
144
+ this.#token = sql_token[0]
145
+
143
146
  let triggerRotation = false
144
147
  let isRecoveryRotation = false
145
148
 
@@ -434,6 +437,7 @@ class Auth {
434
437
  this.#request.cookie('odac_x', '', {'max-age': -1})
435
438
  this.#request.cookie('odac_y', '', {'max-age': -1})
436
439
 
440
+ this.#token = null
437
441
  this.#user = null
438
442
  return true
439
443
  }
@@ -713,6 +717,19 @@ class Auth {
713
717
  })
714
718
  }
715
719
 
720
+ /**
721
+ * Retrieves the active auth token record or a specific column from it.
722
+ * Why: To provide access to the current session's token metadata (e.g., auth ID, IP, date).
723
+ *
724
+ * @param {string|null} [col=null] - The column to retrieve, or null for the full token object.
725
+ * @returns {object|string|number|boolean|false} The token object, column value, or false if no active session.
726
+ */
727
+ token(col = null) {
728
+ if (!this.#token) return false
729
+ if (col === null) return this.#token
730
+ return this.#token[col]
731
+ }
732
+
716
733
  /**
717
734
  * Retrieves the authenticated user or a specific column.
718
735
  * Why: To provide access to the current user's session data securely.