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.
Files changed (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/client/odac.js +1 -1
  3. package/docs/ai/README.md +3 -2
  4. package/docs/ai/skills/SKILL.md +3 -2
  5. package/docs/ai/skills/backend/authentication.md +12 -6
  6. package/docs/ai/skills/backend/database.md +183 -12
  7. package/docs/ai/skills/backend/ipc.md +71 -12
  8. package/docs/ai/skills/backend/migrations.md +23 -0
  9. package/docs/ai/skills/backend/odac-var.md +155 -0
  10. package/docs/ai/skills/backend/utilities.md +1 -1
  11. package/docs/ai/skills/frontend/forms.md +23 -1
  12. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  13. package/docs/backend/04-routing/09-websocket.md +22 -1
  14. package/docs/backend/08-database/05-write-behind-cache.md +230 -0
  15. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  16. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  17. package/docs/backend/10-authentication/05-session-management.md +12 -3
  18. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  19. package/docs/backend/13-utilities/02-ipc.md +117 -0
  20. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  21. package/docs/index.json +1 -1
  22. package/package.json +1 -1
  23. package/src/Auth.js +17 -0
  24. package/src/Database/Migration.js +219 -3
  25. package/src/Database/ReadCache.js +174 -0
  26. package/src/Database/WriteBuffer.js +605 -0
  27. package/src/Database.js +95 -1
  28. package/src/Ipc.js +343 -81
  29. package/src/Odac.js +2 -1
  30. package/src/Storage.js +4 -2
  31. package/src/Validator.js +1 -1
  32. package/src/Var.js +1 -0
  33. package/src/WebSocket.js +80 -23
  34. package/test/Database/Migration/migrate_column.test.js +168 -0
  35. package/test/Database/ReadCache/crossTable.test.js +179 -0
  36. package/test/Database/ReadCache/get.test.js +128 -0
  37. package/test/Database/ReadCache/invalidate.test.js +103 -0
  38. package/test/Database/ReadCache/proxy.test.js +184 -0
  39. package/test/Database/WriteBuffer/_recoverFromCheckpoint.test.js +207 -0
  40. package/test/Database/WriteBuffer/buffer.test.js +143 -0
  41. package/test/Database/WriteBuffer/flush.test.js +192 -0
  42. package/test/Database/WriteBuffer/get.test.js +72 -0
  43. package/test/Database/WriteBuffer/increment.test.js +118 -0
  44. package/test/Database/WriteBuffer/update.test.js +178 -0
  45. package/test/Database/insert.test.js +98 -0
  46. package/test/Ipc/hset.test.js +59 -0
  47. package/test/Ipc/incrBy.test.js +65 -0
  48. package/test/Ipc/lock.test.js +62 -0
  49. package/test/Ipc/rpush.test.js +68 -0
  50. package/test/Ipc/sadd.test.js +68 -0
  51. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  52. package/test/WebSocket/Client/limits.test.js +10 -4
  53. package/test/WebSocket/Client/readyState.test.js +154 -0
  54. 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'] // 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.7",
10
+ "version": "1.4.9",
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.
@@ -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 diff = this._computeDiff(desired, currentColumns, currentIndexes)
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: Index operations — each applied individually for idempotent safety
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()