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
|
@@ -71,3 +71,120 @@ await Odac.Ipc.publish('chat:global', { user: 'Emre', text: 'Hello World' });
|
|
|
71
71
|
|
|
72
72
|
> [!TIP]
|
|
73
73
|
> When using `memory` driver, the subscription listener is registered in the current worker. When a message is published, it goes to the Main process and is then broadcasted to all subscribed workers.
|
|
74
|
+
|
|
75
|
+
### Atomic Counters
|
|
76
|
+
|
|
77
|
+
Use `incrBy` / `decrBy` to atomically increment or decrement a numeric key. These are safe to call from multiple workers simultaneously — no read-then-write race conditions.
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
// Increment by 1 — returns new value
|
|
81
|
+
await Odac.Ipc.incrBy('page:views', 1) // → 1
|
|
82
|
+
await Odac.Ipc.incrBy('page:views', 5) // → 6
|
|
83
|
+
|
|
84
|
+
// Decrement
|
|
85
|
+
await Odac.Ipc.decrBy('page:views', 2) // → 4
|
|
86
|
+
|
|
87
|
+
// Read the result
|
|
88
|
+
const views = await Odac.Ipc.get('page:views') // → 4
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> [!NOTE]
|
|
92
|
+
> Keys that don't exist yet are initialised to `0` before the operation.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### Hash Maps
|
|
97
|
+
|
|
98
|
+
Store and retrieve structured per-key data. Fields are merged on every `hset` call — existing fields not mentioned in the call are preserved.
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
// Set fields (merged, not overwritten)
|
|
102
|
+
await Odac.Ipc.hset('user:42', {active_date: new Date(), last_ip: '1.2.3.4'})
|
|
103
|
+
await Odac.Ipc.hset('user:42', {score: 100})
|
|
104
|
+
|
|
105
|
+
// Retrieve all fields
|
|
106
|
+
const data = await Odac.Ipc.hgetall('user:42')
|
|
107
|
+
// → {active_date: ..., last_ip: '1.2.3.4', score: 100}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### Lists
|
|
113
|
+
|
|
114
|
+
Append items to a shared list and read them back in order. Useful for queues and event streams.
|
|
115
|
+
|
|
116
|
+
```javascript
|
|
117
|
+
// Append items to the right — returns new list length
|
|
118
|
+
await Odac.Ipc.rpush('jobs', {type: 'email', to: 'a@b.com'})
|
|
119
|
+
await Odac.Ipc.rpush('jobs', {type: 'sms'}, {type: 'push'}) // → 3
|
|
120
|
+
|
|
121
|
+
// Read a range (0-indexed, -1 = last item)
|
|
122
|
+
const pending = await Odac.Ipc.lrange('jobs', 0, -1)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### Sets
|
|
128
|
+
|
|
129
|
+
Maintain a collection of unique string members.
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
// Add members
|
|
133
|
+
await Odac.Ipc.sadd('online', 'user:1', 'user:2', 'user:3')
|
|
134
|
+
|
|
135
|
+
// List all members
|
|
136
|
+
const online = await Odac.Ipc.smembers('online') // → ['user:1', 'user:2', 'user:3']
|
|
137
|
+
|
|
138
|
+
// Remove members — returns number of members actually removed
|
|
139
|
+
await Odac.Ipc.srem('online', 'user:2')
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### Distributed Locks
|
|
145
|
+
|
|
146
|
+
Acquire a mutex across all workers and servers before entering a critical section. The TTL prevents deadlocks if a process crashes while holding the lock.
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
// Attempt to acquire the lock (TTL in seconds)
|
|
150
|
+
const acquired = await Odac.Ipc.lock('report:generate', 30)
|
|
151
|
+
|
|
152
|
+
if (!acquired) {
|
|
153
|
+
// Another process is already running this — skip
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Critical section — only one process runs this at a time
|
|
159
|
+
await generateReport()
|
|
160
|
+
} finally {
|
|
161
|
+
// Always release, even on error
|
|
162
|
+
await Odac.Ipc.unlock('report:generate')
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
> [!TIP]
|
|
167
|
+
> With the `redis` driver, locks work across multiple servers — making them true distributed locks.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Method Reference
|
|
172
|
+
|
|
173
|
+
| Method | Description |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `set(key, value, ttl?)` | Store a value, with optional TTL in seconds |
|
|
176
|
+
| `get(key)` | Retrieve a value |
|
|
177
|
+
| `del(key)` | Delete a key |
|
|
178
|
+
| `incrBy(key, delta)` | Atomically increment a numeric key |
|
|
179
|
+
| `decrBy(key, delta)` | Atomically decrement a numeric key |
|
|
180
|
+
| `hset(key, fields)` | Merge fields into a hash map |
|
|
181
|
+
| `hgetall(key)` | Retrieve all fields of a hash map |
|
|
182
|
+
| `rpush(key, ...items)` | Append items to a list |
|
|
183
|
+
| `lrange(key, start, stop)` | Read a range of list items |
|
|
184
|
+
| `sadd(key, ...members)` | Add members to a set |
|
|
185
|
+
| `smembers(key)` | Get all members of a set |
|
|
186
|
+
| `srem(key, ...members)` | Remove members from a set |
|
|
187
|
+
| `lock(key, ttl)` | Acquire a mutex lock |
|
|
188
|
+
| `unlock(key)` | Release a mutex lock |
|
|
189
|
+
| `subscribe(channel, handler)` | Subscribe to a Pub/Sub channel |
|
|
190
|
+
| `publish(channel, message)` | Publish a message to a channel |
|
|
@@ -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']
|
|
206
|
+
messages: ['error']
|
|
206
207
|
}, function(data) {
|
|
207
|
-
|
|
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
package/package.json
CHANGED
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.
|
|
@@ -148,7 +148,8 @@ class Migration {
|
|
|
148
148
|
} else {
|
|
149
149
|
const currentColumns = await this._introspectColumns(knex, tableName)
|
|
150
150
|
const currentIndexes = await this._introspectIndexes(knex, tableName)
|
|
151
|
-
const
|
|
151
|
+
const currentForeignKeys = await this._introspectForeignKeys(knex, tableName)
|
|
152
|
+
const diff = this._computeDiff(desired, currentColumns, currentIndexes, currentForeignKeys)
|
|
152
153
|
|
|
153
154
|
if (diff.length > 0) {
|
|
154
155
|
operations.push(...diff.map(d => ({...d, table: tableName})))
|
|
@@ -379,6 +380,91 @@ class Migration {
|
|
|
379
380
|
return result
|
|
380
381
|
}
|
|
381
382
|
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// FOREIGN KEY INTROSPECTION
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Introspects existing foreign key constraints for a given table.
|
|
389
|
+
* Why: The diff engine needs current FK state to detect when a schema adds, changes,
|
|
390
|
+
* or removes a `references` / `onDelete` / `onUpdate` definition on an existing column.
|
|
391
|
+
* @param {object} knex - Knex instance
|
|
392
|
+
* @param {string} tableName - Table to introspect
|
|
393
|
+
* @returns {Promise<Object>} Map of columnName -> {table, column, onDelete, onUpdate}
|
|
394
|
+
*/
|
|
395
|
+
async _introspectForeignKeys(knex, tableName) {
|
|
396
|
+
const client = knex.client.config.client
|
|
397
|
+
const fks = {}
|
|
398
|
+
|
|
399
|
+
if (client === 'pg') {
|
|
400
|
+
const result = await knex.raw(
|
|
401
|
+
`SELECT
|
|
402
|
+
kcu.column_name,
|
|
403
|
+
ccu.table_name AS foreign_table,
|
|
404
|
+
ccu.column_name AS foreign_column,
|
|
405
|
+
rc.delete_rule,
|
|
406
|
+
rc.update_rule,
|
|
407
|
+
tc.constraint_name
|
|
408
|
+
FROM information_schema.table_constraints tc
|
|
409
|
+
JOIN information_schema.key_column_usage kcu
|
|
410
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
411
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
412
|
+
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
|
413
|
+
JOIN information_schema.referential_constraints rc
|
|
414
|
+
ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema
|
|
415
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ?`,
|
|
416
|
+
[tableName]
|
|
417
|
+
)
|
|
418
|
+
for (const row of result.rows) {
|
|
419
|
+
fks[row.column_name] = {
|
|
420
|
+
table: row.foreign_table,
|
|
421
|
+
column: row.foreign_column,
|
|
422
|
+
onDelete: (row.delete_rule || 'NO ACTION').toUpperCase(),
|
|
423
|
+
onUpdate: (row.update_rule || 'NO ACTION').toUpperCase(),
|
|
424
|
+
constraintName: row.constraint_name
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} else if (client === 'mysql2' || client === 'mysql') {
|
|
428
|
+
const [rows] = await knex.raw(
|
|
429
|
+
`SELECT
|
|
430
|
+
kcu.COLUMN_NAME AS column_name,
|
|
431
|
+
kcu.REFERENCED_TABLE_NAME AS foreign_table,
|
|
432
|
+
kcu.REFERENCED_COLUMN_NAME AS foreign_column,
|
|
433
|
+
rc.DELETE_RULE AS delete_rule,
|
|
434
|
+
rc.UPDATE_RULE AS update_rule,
|
|
435
|
+
kcu.CONSTRAINT_NAME AS constraint_name
|
|
436
|
+
FROM information_schema.KEY_COLUMN_USAGE kcu
|
|
437
|
+
JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
|
|
438
|
+
ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND kcu.TABLE_SCHEMA = rc.CONSTRAINT_SCHEMA
|
|
439
|
+
WHERE kcu.TABLE_SCHEMA = DATABASE() AND kcu.TABLE_NAME = ? AND kcu.REFERENCED_TABLE_NAME IS NOT NULL`,
|
|
440
|
+
[tableName]
|
|
441
|
+
)
|
|
442
|
+
for (const row of rows) {
|
|
443
|
+
fks[row.column_name] = {
|
|
444
|
+
table: row.foreign_table,
|
|
445
|
+
column: row.foreign_column,
|
|
446
|
+
onDelete: (row.delete_rule || 'NO ACTION').toUpperCase(),
|
|
447
|
+
onUpdate: (row.update_rule || 'NO ACTION').toUpperCase(),
|
|
448
|
+
constraintName: row.constraint_name
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} else if (client === 'sqlite3') {
|
|
452
|
+
const result = await knex.raw(`PRAGMA foreign_key_list('${tableName}')`)
|
|
453
|
+
const rows = Array.isArray(result) ? result : []
|
|
454
|
+
for (const row of rows) {
|
|
455
|
+
fks[row.from] = {
|
|
456
|
+
table: row.table,
|
|
457
|
+
column: row.to,
|
|
458
|
+
onDelete: (row.on_delete || 'NO ACTION').toUpperCase(),
|
|
459
|
+
onUpdate: (row.on_update || 'NO ACTION').toUpperCase(),
|
|
460
|
+
constraintName: null
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return fks
|
|
466
|
+
}
|
|
467
|
+
|
|
382
468
|
// ---------------------------------------------------------------------------
|
|
383
469
|
// DIFF ENGINE — Compute desired vs current delta
|
|
384
470
|
// ---------------------------------------------------------------------------
|
|
@@ -391,7 +477,7 @@ class Migration {
|
|
|
391
477
|
* @param {Array} currentIndexes - Introspected index list
|
|
392
478
|
* @returns {Array} Ordered list of diff operations
|
|
393
479
|
*/
|
|
394
|
-
_computeDiff(desired, currentColumns, currentIndexes) {
|
|
480
|
+
_computeDiff(desired, currentColumns, currentIndexes, currentForeignKeys = {}) {
|
|
395
481
|
const ops = []
|
|
396
482
|
const desiredColumns = desired.columns || {}
|
|
397
483
|
const desiredIndexes = desired.indexes || []
|
|
@@ -442,6 +528,34 @@ class Migration {
|
|
|
442
528
|
}
|
|
443
529
|
}
|
|
444
530
|
|
|
531
|
+
// --- Foreign key synchronization ---
|
|
532
|
+
for (const [colName, colDef] of Object.entries(desiredColumns)) {
|
|
533
|
+
if (colDef.type === 'timestamps') continue
|
|
534
|
+
if (!currentColumns[colName]) continue // New column — _addColumn handles FK
|
|
535
|
+
|
|
536
|
+
const desiredRef = colDef.references || null
|
|
537
|
+
const currentFK = currentForeignKeys[colName] || null
|
|
538
|
+
const desiredOnDelete = (colDef.onDelete || 'NO ACTION').toUpperCase()
|
|
539
|
+
const desiredOnUpdate = (colDef.onUpdate || 'NO ACTION').toUpperCase()
|
|
540
|
+
|
|
541
|
+
if (desiredRef && !currentFK) {
|
|
542
|
+
// FK added to existing column
|
|
543
|
+
ops.push({type: 'add_foreign_key', column: colName, definition: colDef})
|
|
544
|
+
} else if (!desiredRef && currentFK) {
|
|
545
|
+
// FK removed from schema
|
|
546
|
+
ops.push({type: 'drop_foreign_key', column: colName, constraintName: currentFK.constraintName})
|
|
547
|
+
} else if (desiredRef && currentFK) {
|
|
548
|
+
// FK exists — check if target table/column or actions changed
|
|
549
|
+
const targetChanged = desiredRef.table !== currentFK.table || desiredRef.column !== currentFK.column
|
|
550
|
+
const actionChanged = desiredOnDelete !== currentFK.onDelete || desiredOnUpdate !== currentFK.onUpdate
|
|
551
|
+
|
|
552
|
+
if (targetChanged || actionChanged) {
|
|
553
|
+
ops.push({type: 'drop_foreign_key', column: colName, constraintName: currentFK.constraintName})
|
|
554
|
+
ops.push({type: 'add_foreign_key', column: colName, definition: colDef})
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
445
559
|
// --- Index synchronization ---
|
|
446
560
|
const desiredIndexSignatures = new Set(desiredIndexes.map(idx => this._indexSignature(idx)))
|
|
447
561
|
const currentIndexSignatures = new Set(currentIndexes.map(idx => this._indexSignature(idx)))
|
|
@@ -481,9 +595,41 @@ class Migration {
|
|
|
481
595
|
// drivers (SQLite) return maxLength as a string, e.g. '100' vs 100.
|
|
482
596
|
if (desired.length && current.maxLength && Number(desired.length) !== Number(current.maxLength)) return true
|
|
483
597
|
|
|
598
|
+
// Default value mismatch — normalize both sides before comparing because
|
|
599
|
+
// drivers return defaults as strings (e.g. "'active'" in PG, "active" in SQLite).
|
|
600
|
+
const desiredDefault = desired.default !== undefined ? this._normalizeDefaultValue(desired.default) : null
|
|
601
|
+
const currentDefault =
|
|
602
|
+
current.defaultValue !== undefined && current.defaultValue !== null ? this._normalizeDefaultValue(current.defaultValue) : null
|
|
603
|
+
|
|
604
|
+
if (desiredDefault !== currentDefault) return true
|
|
605
|
+
|
|
484
606
|
return false
|
|
485
607
|
}
|
|
486
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Normalizes a column default value to a canonical string for cross-driver comparison.
|
|
611
|
+
* Why: Each DB driver serializes defaults differently — PG wraps strings in single quotes
|
|
612
|
+
* and appends type casts (e.g. `'active'::character varying`), SQLite returns raw values,
|
|
613
|
+
* MySQL returns unquoted strings. Stripping quotes and casts gives a stable comparison key.
|
|
614
|
+
* @param {*} value - Raw default value from schema definition or DB introspection
|
|
615
|
+
* @returns {string} Normalized string representation
|
|
616
|
+
*/
|
|
617
|
+
_normalizeDefaultValue(value) {
|
|
618
|
+
if (value === null || value === undefined) return 'null'
|
|
619
|
+
|
|
620
|
+
let str = String(value)
|
|
621
|
+
|
|
622
|
+
// Strip PG type cast suffix: 'foo'::character varying → 'foo'
|
|
623
|
+
str = str.replace(/::[\w\s]+$/, '')
|
|
624
|
+
|
|
625
|
+
// Strip surrounding single quotes added by PG/MySQL: 'foo' → foo
|
|
626
|
+
if (str.startsWith("'") && str.endsWith("'")) {
|
|
627
|
+
str = str.slice(1, -1)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return str.trim().toLowerCase()
|
|
631
|
+
}
|
|
632
|
+
|
|
487
633
|
/**
|
|
488
634
|
* Generates a deterministic signature for an index to enable set comparison.
|
|
489
635
|
* @param {object} idx - Index definition {columns, unique}
|
|
@@ -538,6 +684,7 @@ class Migration {
|
|
|
538
684
|
async _applyDiff(knex, tableName, diff) {
|
|
539
685
|
const columnOps = diff.filter(op => op.type === 'add_column' || op.type === 'drop_column' || op.type === 'alter_column')
|
|
540
686
|
const indexOps = diff.filter(op => op.type === 'add_index' || op.type === 'drop_index')
|
|
687
|
+
const fkOps = diff.filter(op => op.type === 'add_foreign_key' || op.type === 'drop_foreign_key')
|
|
541
688
|
|
|
542
689
|
// Phase 1: Column operations — atomic batch
|
|
543
690
|
if (columnOps.length > 0) {
|
|
@@ -558,7 +705,19 @@ class Migration {
|
|
|
558
705
|
})
|
|
559
706
|
}
|
|
560
707
|
|
|
561
|
-
// Phase 2:
|
|
708
|
+
// Phase 2: Foreign key operations — drop before add to handle replacements
|
|
709
|
+
for (const op of fkOps) {
|
|
710
|
+
if (op.type === 'drop_foreign_key') {
|
|
711
|
+
await this._applyForeignKeyOp(knex, tableName, op)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const op of fkOps) {
|
|
715
|
+
if (op.type === 'add_foreign_key') {
|
|
716
|
+
await this._applyForeignKeyOp(knex, tableName, op)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Phase 3: Index operations — each applied individually for idempotent safety
|
|
562
721
|
for (const op of indexOps) {
|
|
563
722
|
await this._applyIndexOp(knex, tableName, op)
|
|
564
723
|
}
|
|
@@ -606,6 +765,63 @@ class Migration {
|
|
|
606
765
|
}
|
|
607
766
|
}
|
|
608
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Applies a single foreign key add/drop operation with idempotent error handling.
|
|
770
|
+
* Why: Knex's col.alter() cannot manage FK constraints — they require table-level
|
|
771
|
+
* alterTable calls (add .foreign() / drop .dropForeign()) which are separate from column ops.
|
|
772
|
+
* @param {object} knex - Knex instance
|
|
773
|
+
* @param {string} tableName - Target table
|
|
774
|
+
* @param {object} op - FK operation {type, column, definition?, constraintName?}
|
|
775
|
+
*/
|
|
776
|
+
async _applyForeignKeyOp(knex, tableName, op) {
|
|
777
|
+
try {
|
|
778
|
+
if (op.type === 'add_foreign_key') {
|
|
779
|
+
const ref = op.definition.references
|
|
780
|
+
|
|
781
|
+
// Clean orphan rows before adding constraint — existing data may reference
|
|
782
|
+
// rows that no longer exist in the parent table, which would cause PG error 23503.
|
|
783
|
+
// Nullable columns get SET NULL safely. Non-nullable columns are NOT deleted —
|
|
784
|
+
// instead the constraint is skipped with a warning to prevent silent data loss.
|
|
785
|
+
const orphanCondition = knex(tableName).whereNotIn(op.column, knex(ref.table).select(ref.column)).whereNotNull(op.column)
|
|
786
|
+
|
|
787
|
+
if (op.definition.nullable !== false) {
|
|
788
|
+
await orphanCondition.update({[op.column]: null})
|
|
789
|
+
} else {
|
|
790
|
+
const [{count: orphanCount}] = await orphanCondition.clone().count('* as count')
|
|
791
|
+
|
|
792
|
+
if (Number(orphanCount) > 0) {
|
|
793
|
+
console.error(
|
|
794
|
+
`\x1b[31m[ODAC Migration]\x1b[0m Skipping foreign key on "${tableName}.${op.column}" → ` +
|
|
795
|
+
`"${ref.table}.${ref.column}": ${orphanCount} orphan row(s) found. ` +
|
|
796
|
+
`Column is NOT NULL so rows cannot be nullified. ` +
|
|
797
|
+
`Clean the data manually and restart, or make the column nullable.`
|
|
798
|
+
)
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
await knex.schema.alterTable(tableName, table => {
|
|
804
|
+
const fk = table.foreign(op.column).references(ref.column).inTable(ref.table)
|
|
805
|
+
if (op.definition.onDelete) fk.onDelete(op.definition.onDelete)
|
|
806
|
+
if (op.definition.onUpdate) fk.onUpdate(op.definition.onUpdate)
|
|
807
|
+
})
|
|
808
|
+
} else if (op.type === 'drop_foreign_key') {
|
|
809
|
+
await knex.schema.alterTable(tableName, table => {
|
|
810
|
+
table.dropForeign(op.column)
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
} catch (e) {
|
|
814
|
+
const isDuplicate = e.message && (e.message.includes('already exists') || e.code === '42710')
|
|
815
|
+
const isNotFound = e.message && (e.message.includes('does not exist') || e.code === '42704')
|
|
816
|
+
|
|
817
|
+
if ((op.type === 'add_foreign_key' && isDuplicate) || (op.type === 'drop_foreign_key' && isNotFound)) {
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
throw e
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
609
825
|
/**
|
|
610
826
|
* Translates schema column definitions into Knex schema builder calls.
|
|
611
827
|
* Supports all common column types with their modifiers.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const nodeCrypto = require('node:crypto')
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read-Through Cache for ODAC Database layer.
|
|
6
|
+
*
|
|
7
|
+
* Why: Frequently-read, rarely-changed data (blog posts, settings, categories)
|
|
8
|
+
* generates redundant DB queries across workers. This module caches SELECT results
|
|
9
|
+
* via the Ipc layer, providing O(1) reads after the first query.
|
|
10
|
+
*
|
|
11
|
+
* Architecture: Fully delegated to Odac.Ipc for state management (same as WriteBuffer).
|
|
12
|
+
* - Memory driver: Primary process holds cached data in Maps via cluster IPC.
|
|
13
|
+
* - Redis driver: All state lives in Redis — works across horizontal load balancers.
|
|
14
|
+
* - Both drivers: TTL-based expiration + automatic invalidation on write operations.
|
|
15
|
+
*
|
|
16
|
+
* Key namespaces in Ipc:
|
|
17
|
+
* rc:{connection}:{table}:{queryHash} — cached query result (Ipc.set/get with TTL)
|
|
18
|
+
* rc:idx:{connection}:{table} — set of active cache keys for bulk invalidation
|
|
19
|
+
*
|
|
20
|
+
* API (exposed via Database.js proxy):
|
|
21
|
+
* Odac.DB.posts.cache(60).where({active: true}).select('id', 'title') — TTL cache
|
|
22
|
+
* Odac.DB.posts.cache().where({id: 5}).first() — default TTL
|
|
23
|
+
* Odac.DB.posts.cache.clear() — table invalidation
|
|
24
|
+
* Odac.DB.posts.cache.clear({id: 5}) — targeted invalidation
|
|
25
|
+
*
|
|
26
|
+
* Automatic invalidation:
|
|
27
|
+
* insert/update/delete on a table → all cached queries for that table are purged.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFIG = {
|
|
31
|
+
ttl: 300,
|
|
32
|
+
maxKeys: 10000
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ReadCache {
|
|
36
|
+
constructor() {
|
|
37
|
+
this._config = {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Why: Merges user config with sensible defaults. Called from Database.init().
|
|
42
|
+
* No timers or background processes — cache is purely reactive (read-through + TTL).
|
|
43
|
+
*/
|
|
44
|
+
init() {
|
|
45
|
+
this._config = {...DEFAULT_CONFIG, ...Odac.Config.cache}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why: Generates a deterministic cache key from the Knex query builder's compiled SQL + bindings.
|
|
50
|
+
* SHA-256 ensures fixed-length keys regardless of query complexity.
|
|
51
|
+
* Sorting bindings is unnecessary — Knex preserves parameter order deterministically.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} connection - Connection key (e.g., 'default', 'analytics')
|
|
54
|
+
* @param {string} table - Table name
|
|
55
|
+
* @param {object} queryBuilder - Knex query builder instance
|
|
56
|
+
* @returns {string} Cache key in format rc:{connection}:{table}:{hash}
|
|
57
|
+
*/
|
|
58
|
+
buildKey(connection, table, queryBuilder) {
|
|
59
|
+
const {sql, bindings} = queryBuilder.toSQL()
|
|
60
|
+
const hash = nodeCrypto
|
|
61
|
+
.createHash('sha256')
|
|
62
|
+
.update(`${sql}:${JSON.stringify(bindings)}`)
|
|
63
|
+
.digest('hex')
|
|
64
|
+
return `rc:${connection}:${table}:${hash}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Why: Core read-through logic. Returns cached result on HIT, executes query on MISS.
|
|
69
|
+
* TTL ensures eventual consistency even without explicit invalidation.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} connection - Connection key
|
|
72
|
+
* @param {string} table - Table name
|
|
73
|
+
* @param {object} queryBuilder - Knex query builder (used for key generation via .toSQL())
|
|
74
|
+
* @param {function} executeFn - Callback that executes the original DB query (avoids .then() recursion)
|
|
75
|
+
* @param {number} ttl - Time-to-live in seconds
|
|
76
|
+
* @returns {Promise<*>} Query result (from cache or DB)
|
|
77
|
+
*/
|
|
78
|
+
async get(connection, table, queryBuilder, executeFn, ttl) {
|
|
79
|
+
const effectiveTtl = ttl || this._config.ttl
|
|
80
|
+
const cacheKey = this.buildKey(connection, table, queryBuilder)
|
|
81
|
+
|
|
82
|
+
// O(1) cache lookup via Ipc — sentinel envelope {__v} distinguishes "cached null" from MISS
|
|
83
|
+
const cached = await Odac.Ipc.get(cacheKey)
|
|
84
|
+
if (cached !== null && typeof cached === 'object' && '__v' in cached) return cached.__v
|
|
85
|
+
|
|
86
|
+
// MISS — execute the actual DB query via the original (unwrapped) .then()
|
|
87
|
+
const result = await executeFn()
|
|
88
|
+
|
|
89
|
+
// Guard against exceeding maxKeys to prevent unbounded memory growth
|
|
90
|
+
const indexKey = `rc:idx:${connection}:${table}`
|
|
91
|
+
const currentKeys = await Odac.Ipc.smembers(indexKey)
|
|
92
|
+
|
|
93
|
+
if (currentKeys.length < this._config.maxKeys) {
|
|
94
|
+
await Odac.Ipc.set(cacheKey, {__v: result}, effectiveTtl)
|
|
95
|
+
await Odac.Ipc.sadd(indexKey, cacheKey)
|
|
96
|
+
|
|
97
|
+
// Cross-table invalidation: register cache key in joined tables' indexes too.
|
|
98
|
+
// Why: A query like posts.join('users').cache().select() must be invalidated
|
|
99
|
+
// when EITHER posts OR users is written to. Without this, a users.insert()
|
|
100
|
+
// would leave stale joined data in the posts cache.
|
|
101
|
+
const joinedTables = this._extractJoinedTables(queryBuilder)
|
|
102
|
+
for (const joinedTable of joinedTables) {
|
|
103
|
+
await Odac.Ipc.sadd(`rc:idx:${connection}:${joinedTable}`, cacheKey)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why: Purges all cached queries for a specific table. Called automatically on
|
|
112
|
+
* insert/update/delete via Database.js proxy intercept.
|
|
113
|
+
* Table-level granularity is intentional — row-level invalidation would require
|
|
114
|
+
* parsing WHERE clauses of cached queries, which is O(n) and error-prone.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} connection - Connection key
|
|
117
|
+
* @param {string} table - Table name
|
|
118
|
+
* @returns {Promise<void>}
|
|
119
|
+
*/
|
|
120
|
+
async invalidate(connection, table) {
|
|
121
|
+
if (!global.Odac?.Ipc) return
|
|
122
|
+
|
|
123
|
+
const indexKey = `rc:idx:${connection}:${table}`
|
|
124
|
+
const keys = await Odac.Ipc.smembers(indexKey)
|
|
125
|
+
|
|
126
|
+
if (keys.length === 0) return
|
|
127
|
+
|
|
128
|
+
// Delete all cached entries in parallel
|
|
129
|
+
await Promise.all(keys.map(key => Odac.Ipc.del(key)))
|
|
130
|
+
await Odac.Ipc.del(indexKey)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Why: Extracts table names from JOIN clauses in a Knex query builder.
|
|
135
|
+
* Used to register cache keys in joined tables' indexes for cross-table invalidation.
|
|
136
|
+
* Parses Knex's internal _statements array — entries with a joinType property
|
|
137
|
+
* contain the joined table name. Handles aliased tables (e.g., 'users as u' → 'users').
|
|
138
|
+
*
|
|
139
|
+
* @param {object} queryBuilder - Knex query builder instance
|
|
140
|
+
* @returns {string[]} Array of joined table names (deduplicated)
|
|
141
|
+
*/
|
|
142
|
+
_extractJoinedTables(queryBuilder) {
|
|
143
|
+
const statements = queryBuilder._statements
|
|
144
|
+
if (!statements || !Array.isArray(statements)) return []
|
|
145
|
+
|
|
146
|
+
const tables = new Set()
|
|
147
|
+
for (const stmt of statements) {
|
|
148
|
+
if (!stmt.joinType || !stmt.table) continue
|
|
149
|
+
// Handle aliased tables: 'users as u' → 'users'
|
|
150
|
+
const tableName = String(stmt.table)
|
|
151
|
+
.split(/\s+as\s+/i)[0]
|
|
152
|
+
.trim()
|
|
153
|
+
if (tableName) tables.add(tableName)
|
|
154
|
+
}
|
|
155
|
+
return [...tables]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Why: Targeted invalidation — clears cache entries whose query hash matches
|
|
160
|
+
* a specific WHERE condition. Falls back to full table invalidation since
|
|
161
|
+
* precise row-level matching against arbitrary cached SQL is not feasible.
|
|
162
|
+
* Exposed as Odac.DB.posts.cache.clear({id: 5}) for semantic clarity,
|
|
163
|
+
* but internally equivalent to table-level purge.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} connection - Connection key
|
|
166
|
+
* @param {string} table - Table name
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
async clear(connection, table) {
|
|
170
|
+
return this.invalidate(connection, table)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = new ReadCache()
|