odac 1.4.7 → 1.4.9
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 +45 -0
- package/client/odac.js +1 -1
- package/docs/ai/README.md +3 -2
- package/docs/ai/skills/SKILL.md +3 -2
- package/docs/ai/skills/backend/authentication.md +12 -6
- package/docs/ai/skills/backend/database.md +183 -12
- package/docs/ai/skills/backend/ipc.md +71 -12
- package/docs/ai/skills/backend/migrations.md +23 -0
- package/docs/ai/skills/backend/odac-var.md +155 -0
- package/docs/ai/skills/backend/utilities.md +1 -1
- package/docs/ai/skills/frontend/forms.md +23 -1
- package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
- package/docs/backend/04-routing/09-websocket.md +22 -1
- package/docs/backend/08-database/05-write-behind-cache.md +230 -0
- package/docs/backend/08-database/06-read-through-cache.md +206 -0
- package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
- package/docs/backend/10-authentication/05-session-management.md +12 -3
- package/docs/backend/13-utilities/01-odac-var.md +13 -19
- package/docs/backend/13-utilities/02-ipc.md +117 -0
- package/docs/frontend/03-forms/01-form-handling.md +15 -2
- package/docs/index.json +1 -1
- package/package.json +1 -1
- package/src/Auth.js +17 -0
- package/src/Database/Migration.js +219 -3
- package/src/Database/ReadCache.js +174 -0
- package/src/Database/WriteBuffer.js +605 -0
- package/src/Database.js +95 -1
- package/src/Ipc.js +343 -81
- package/src/Odac.js +2 -1
- package/src/Storage.js +4 -2
- package/src/Validator.js +1 -1
- package/src/Var.js +1 -0
- package/src/WebSocket.js +80 -23
- package/test/Database/Migration/migrate_column.test.js +168 -0
- package/test/Database/ReadCache/crossTable.test.js +179 -0
- package/test/Database/ReadCache/get.test.js +128 -0
- package/test/Database/ReadCache/invalidate.test.js +103 -0
- package/test/Database/ReadCache/proxy.test.js +184 -0
- package/test/Database/WriteBuffer/_recoverFromCheckpoint.test.js +207 -0
- package/test/Database/WriteBuffer/buffer.test.js +143 -0
- package/test/Database/WriteBuffer/flush.test.js +192 -0
- package/test/Database/WriteBuffer/get.test.js +72 -0
- package/test/Database/WriteBuffer/increment.test.js +118 -0
- package/test/Database/WriteBuffer/update.test.js +178 -0
- package/test/Database/insert.test.js +98 -0
- package/test/Ipc/hset.test.js +59 -0
- package/test/Ipc/incrBy.test.js +65 -0
- package/test/Ipc/lock.test.js +62 -0
- package/test/Ipc/rpush.test.js +68 -0
- package/test/Ipc/sadd.test.js +68 -0
- package/test/WebSocket/Client/fragmentation.test.js +130 -0
- package/test/WebSocket/Client/limits.test.js +10 -4
- package/test/WebSocket/Client/readyState.test.js +154 -0
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
|
@@ -42,7 +42,29 @@ Odac.form(
|
|
|
42
42
|
}
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
-
// 5)
|
|
45
|
+
// 5) Disable all automatic messages and handle everything in the callback
|
|
46
|
+
Odac.form(
|
|
47
|
+
{form: 'form[data-odac-form]', messages: false},
|
|
48
|
+
response => {
|
|
49
|
+
if (response?.result?.success) {
|
|
50
|
+
// Custom success: e.g. show a toast, update UI
|
|
51
|
+
} else if (response?.errors) {
|
|
52
|
+
// Custom error: manually map errors to DOM
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// 6) Only show success messages, handle errors manually
|
|
58
|
+
Odac.form(
|
|
59
|
+
{form: 'form[data-odac-form]', messages: ['success']},
|
|
60
|
+
response => {
|
|
61
|
+
if (!response?.result?.success && response?.errors) {
|
|
62
|
+
// Custom error handling
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// 7) Manual GET request helper
|
|
46
68
|
Odac.get('/api/status', data => {
|
|
47
69
|
const status = document.querySelector('[data-api-status]')
|
|
48
70
|
if (status) status.textContent = String(data?.status ?? '')
|
|
@@ -31,6 +31,7 @@ Odac.Route.ws('/path', Odac => {
|
|
|
31
31
|
| Property | Description |
|
|
32
32
|
|----------|-------------|
|
|
33
33
|
| `Odac.ws.id` | Unique client ID |
|
|
34
|
+
| `Odac.ws.readyState` | RFC 6455 state: `0` CONNECTING, `1` OPEN, `2` CLOSING, `3` CLOSED |
|
|
34
35
|
| `Odac.ws.rooms` | Array of joined rooms |
|
|
35
36
|
| `Odac.ws.data` | Custom data storage |
|
|
36
37
|
|
|
@@ -169,14 +170,33 @@ const ws = Odac.ws('/chat', {shared: true})
|
|
|
169
170
|
|------|-------------|
|
|
170
171
|
| `1000` | Normal closure |
|
|
171
172
|
| `1001` | Going away |
|
|
172
|
-
| `1002` | Protocol error |
|
|
173
|
+
| `1002` | Protocol error (e.g. unexpected continuation frame) |
|
|
173
174
|
| `1003` | Unsupported data |
|
|
174
175
|
| `1006` | Abnormal closure |
|
|
176
|
+
| `1008` | Rate limit exceeded |
|
|
177
|
+
| `1009` | Payload too large |
|
|
175
178
|
| `4000` | Middleware rejected |
|
|
176
179
|
| `4001` | Unauthorized |
|
|
177
180
|
| `4002` | Invalid/missing token |
|
|
178
181
|
| `4003` | Forbidden (middleware) |
|
|
179
182
|
|
|
183
|
+
## readyState Constants
|
|
184
|
+
|
|
185
|
+
| Value | Constant | Description |
|
|
186
|
+
|-------|----------|-------------|
|
|
187
|
+
| `0` | `CONNECTING` | Handshake done, handler not yet invoked |
|
|
188
|
+
| `1` | `OPEN` | Active — messages can be sent/received |
|
|
189
|
+
| `2` | `CLOSING` | Close frame sent, draining |
|
|
190
|
+
| `3` | `CLOSED` | Fully terminated |
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
const {READY_STATE} = require('odac')
|
|
194
|
+
|
|
195
|
+
if (Odac.ws.readyState === READY_STATE.OPEN) {
|
|
196
|
+
Odac.ws.send({status: 'alive'})
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
180
200
|
## Best Practices
|
|
181
201
|
|
|
182
202
|
1. **Always handle authentication**
|
|
@@ -97,9 +97,30 @@ Odac.ws.on('error', err => {}) // Error occurred
|
|
|
97
97
|
### Connection Management
|
|
98
98
|
|
|
99
99
|
```javascript
|
|
100
|
-
Odac.ws.close() // Close connection
|
|
100
|
+
Odac.ws.close() // Close connection (sends close frame, transitions to CLOSED)
|
|
101
101
|
Odac.ws.ping() // Send ping frame
|
|
102
102
|
Odac.ws.id // Unique client ID
|
|
103
|
+
Odac.ws.readyState // Current connection state (0–3)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Connection State
|
|
107
|
+
|
|
108
|
+
`Odac.ws.readyState` reflects the RFC 6455 lifecycle. Use the static constants on `WebSocketClient` or the exported `READY_STATE` enum for readable comparisons:
|
|
109
|
+
|
|
110
|
+
| Value | Constant | Description |
|
|
111
|
+
|-------|----------|-------------|
|
|
112
|
+
| `0` | `CONNECTING` | Handshake complete, handler not yet called |
|
|
113
|
+
| `1` | `OPEN` | Connection active, messages can be sent |
|
|
114
|
+
| `2` | `CLOSING` | Close frame sent, waiting for socket drain |
|
|
115
|
+
| `3` | `CLOSED` | Connection fully terminated |
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const {READY_STATE} = require('odac')
|
|
119
|
+
|
|
120
|
+
Odac.ws.on('message', data => {
|
|
121
|
+
if (Odac.ws.readyState !== READY_STATE.OPEN) return
|
|
122
|
+
Odac.ws.send({echo: data})
|
|
123
|
+
})
|
|
103
124
|
```
|
|
104
125
|
|
|
105
126
|
## Rooms
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Write-Behind Cache
|
|
2
|
+
|
|
3
|
+
At high traffic, individual database writes for common operations — like incrementing a page view counter or stamping a user's last-active date — quickly saturate your connection pool. One million page views = one million `UPDATE` queries.
|
|
4
|
+
|
|
5
|
+
ODAC's **Write-Behind Cache** solves this by buffering writes in memory and flushing them to the database in efficient batches. The only change to your code is adding `.buffer` to the chain.
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
// Without buffer — 1 DB write per request
|
|
9
|
+
await Odac.DB.posts.where(postId).update({views: Odac.DB.raw('views + 1')})
|
|
10
|
+
|
|
11
|
+
// With buffer — 1 DB write per flush interval, for all requests combined
|
|
12
|
+
await Odac.DB.posts.buffer.where(postId).increment('views')
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## How It Works
|
|
18
|
+
|
|
19
|
+
**Architecture: Ipc-Backed, Driver-Agnostic**
|
|
20
|
+
|
|
21
|
+
All buffered state is held in `Odac.Ipc`. The active IPC driver determines the scaling model:
|
|
22
|
+
|
|
23
|
+
| Driver | Scope | When to use |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `memory` (default) | Single machine — cluster workers share state via Primary process | Single-server deployments |
|
|
26
|
+
| `redis` | Multi-machine — all servers share state in Redis | Horizontal scaling behind a load balancer |
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
// Memory driver (default)
|
|
30
|
+
Worker 1 ─┐
|
|
31
|
+
Worker 2 ─┼──→ Primary (Ipc memory store) ──→ DB (batch flush every 5s)
|
|
32
|
+
Worker N ─┘
|
|
33
|
+
|
|
34
|
+
// Redis driver
|
|
35
|
+
Server A ─┐
|
|
36
|
+
Server B ─┼──→ Redis (Ipc state) ──→ DB (flush — distributed lock prevents duplicate writes)
|
|
37
|
+
Server C ─┘
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A **distributed lock** (`Ipc.lock`) guarantees that only one process or server flushes at a time, even across multiple machines.
|
|
41
|
+
|
|
42
|
+
**Crash Safety via LMDB Checkpoint** *(memory driver only)*
|
|
43
|
+
|
|
44
|
+
Every 30 seconds, pending buffer data is written to the local LMDB store. On a crash and restart, ODAC recovers this checkpoint and flushes it to the database before accepting any traffic. When using the Redis driver, Redis itself provides durability — LMDB checkpoints are skipped.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Three Operations
|
|
49
|
+
|
|
50
|
+
### 1. Counter Increment
|
|
51
|
+
|
|
52
|
+
Accumulates numeric deltas. Multiple increments to the same column merge into a single `UPDATE col = col + delta` at flush time.
|
|
53
|
+
|
|
54
|
+
```javascript
|
|
55
|
+
// Increment by 1 (default)
|
|
56
|
+
await Odac.DB.posts.buffer.where(postId).increment('views')
|
|
57
|
+
|
|
58
|
+
// Increment by a custom amount
|
|
59
|
+
await Odac.DB.posts.buffer.where(postId).increment('likes', 5)
|
|
60
|
+
await Odac.DB.downloads.buffer.where(fileId).increment('count', 3)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Read the current value** — returns `DB base + pending delta`, always accurate:
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const currentViews = await Odac.DB.posts.buffer.where(postId).get('views')
|
|
67
|
+
// → 4527 (e.g., 4500 in DB + 27 buffered, not yet flushed)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Composite primary key:**
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
await Odac.DB.post_stats.buffer
|
|
74
|
+
.where({post_id: 123, date: '2026-04-01'})
|
|
75
|
+
.increment('views')
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### 2. Last-Write-Wins Update
|
|
81
|
+
|
|
82
|
+
Buffers column SET operations for a row. If the same row is updated multiple times before a flush, the values are merged — the latest value for each column wins. The entire pending set for a row is written in a single `UPDATE` at flush.
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
// 50 requests update the same user → 1 UPDATE at flush
|
|
86
|
+
await Odac.DB.users.buffer.where(userId).update({active_date: new Date()})
|
|
87
|
+
await Odac.DB.users.buffer.where(userId).update({last_ip: req.ip})
|
|
88
|
+
// → UPDATE users SET active_date = ?, last_ip = ? WHERE id = ? (one query for all 50 requests)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Composite primary key:**
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
await Odac.DB.user_prefs.buffer
|
|
95
|
+
.where({user_id: 1, pref_key: 'theme'})
|
|
96
|
+
.update({pref_value: 'dark'})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Combine with increment** — both flush in the same cycle:
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
await Odac.DB.users.buffer.where(userId).increment('login_count')
|
|
103
|
+
await Odac.DB.users.buffer.where(userId).update({active_date: new Date(), last_ip: req.ip})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### 3. Batch Insert
|
|
109
|
+
|
|
110
|
+
Queues rows in memory and inserts them in chunks of 1,000 at flush time. Ideal for audit logs, analytics events, and activity streams where individual inserts are wasteful.
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
await Odac.DB.activity_log.buffer.insert({
|
|
114
|
+
user_id: userId,
|
|
115
|
+
action: 'page_view',
|
|
116
|
+
meta: req.url,
|
|
117
|
+
created_at: Date.now()
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The queue auto-flushes immediately if it exceeds `maxQueueSize` (default: 10,000 rows).
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Manual Flush
|
|
126
|
+
|
|
127
|
+
Force an immediate flush for a specific table or for all buffered tables:
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
// Flush a single table
|
|
131
|
+
await Odac.DB.posts.buffer.flush()
|
|
132
|
+
|
|
133
|
+
// Flush all buffered tables across all connections
|
|
134
|
+
await Odac.DB.buffer.flush()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
> Graceful shutdown (`SIGTERM`/`SIGINT`) triggers a final flush automatically before the DB connections are closed. You do not need to call `flush()` in your shutdown handlers.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
Add a `buffer` section to your `odac.json`:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"buffer": {
|
|
148
|
+
"flushInterval": 5000,
|
|
149
|
+
"checkpointInterval": 30000,
|
|
150
|
+
"maxQueueSize": 10000,
|
|
151
|
+
"primaryKey": "id"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Option | Default | Description |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `flushInterval` | `5000` | How often (ms) to flush pending data to the database |
|
|
159
|
+
| `checkpointInterval` | `30000` | How often (ms) to write a crash-recovery checkpoint to LMDB *(memory driver only)* |
|
|
160
|
+
| `maxQueueSize` | `10000` | Auto-flush the insert queue when it reaches this many rows |
|
|
161
|
+
| `primaryKey` | `"id"` | Default primary key column name for scalar `where()` values |
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Horizontal Scaling
|
|
166
|
+
|
|
167
|
+
To share buffer state across multiple servers, switch the `ipc` driver to `redis`:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"ipc": {
|
|
172
|
+
"driver": "redis",
|
|
173
|
+
"redis": "default"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
With the Redis driver active:
|
|
179
|
+
- All `increment`, `update`, and `insert` operations go to Redis atomically.
|
|
180
|
+
- Any server can trigger a `flush()` — the distributed lock ensures no server writes twice.
|
|
181
|
+
- LMDB checkpoints are skipped (Redis persistence provides the durability guarantee).
|
|
182
|
+
|
|
183
|
+
No code changes are required in your application. The `.buffer` API is identical regardless of driver.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Named Database Connections
|
|
188
|
+
|
|
189
|
+
The buffer respects your multi-connection configuration. Access it via the named connection, then the table:
|
|
190
|
+
|
|
191
|
+
```javascript
|
|
192
|
+
// Default connection
|
|
193
|
+
await Odac.DB.posts.buffer.where(postId).increment('views')
|
|
194
|
+
|
|
195
|
+
// Named connection: 'analytics'
|
|
196
|
+
await Odac.DB.analytics.events.buffer.insert({type: 'click', target: '#cta'})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Guarantees
|
|
202
|
+
|
|
203
|
+
| Scenario | Behaviour |
|
|
204
|
+
|---|---|
|
|
205
|
+
| Worker crash | No data loss — all state is in the Primary process (memory) or Redis |
|
|
206
|
+
| Primary crash | Pending data recovered from LMDB checkpoint on next startup *(memory driver)* |
|
|
207
|
+
| Server crash (Redis) | Pending data is durable in Redis — recovered on next flush cycle |
|
|
208
|
+
| DB flush error | Data is retained in Ipc and retried on the next flush cycle |
|
|
209
|
+
| Graceful shutdown | Automatic final flush before connections close |
|
|
210
|
+
| `get()` after `increment()` | Returns base + buffered delta — always accurate, no extra DB read |
|
|
211
|
+
| Concurrent workers | Primary serializes all writes (memory) or Redis atomic ops prevent races |
|
|
212
|
+
| Multiple servers | Distributed lock guarantees exactly one flush at a time |
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## When to Use (and Not Use)
|
|
217
|
+
|
|
218
|
+
**Use Write-Behind Cache for:**
|
|
219
|
+
- Page/post view counters
|
|
220
|
+
- Download counters, like/upvote counts
|
|
221
|
+
- User last-active timestamps, last IP
|
|
222
|
+
- Activity logs, analytics events, audit trails
|
|
223
|
+
- Any write that is not immediately safety-critical and occurs on every request
|
|
224
|
+
|
|
225
|
+
**Do not use for:**
|
|
226
|
+
- Operations where the write must be visible to the *same* request that triggered it
|
|
227
|
+
- Inserts that return generated IDs you need immediately (use direct `insert()`)
|
|
228
|
+
|
|
229
|
+
> [!WARNING]
|
|
230
|
+
> **Never use Write-Behind Cache for financial or safety-critical operations** — payment records, order confirmations, balance changes, inventory decrements, or any write where data loss or a delayed flush would have real-world consequences. The buffer does not guarantee that data reaches the database before a crash. Use direct database transactions for anything that matters immediately.
|
|
@@ -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(
|
|
152
|
-
const
|
|
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
|
-
'
|
|
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() -
|
|
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: '$
|
|
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
|
|
202
|
+
#### hashCheck() - Verify scrypt hash
|
|
208
203
|
|
|
209
204
|
```javascript
|
|
210
|
-
const hashedPassword = '$
|
|
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
|
-
-
|
|
502
|
-
-
|
|
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
|