odac 1.4.8 → 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 (35) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/docs/ai/README.md +2 -1
  3. package/docs/ai/skills/SKILL.md +2 -1
  4. package/docs/ai/skills/backend/authentication.md +12 -6
  5. package/docs/ai/skills/backend/database.md +85 -5
  6. package/docs/ai/skills/backend/migrations.md +23 -0
  7. package/docs/ai/skills/backend/odac-var.md +155 -0
  8. package/docs/ai/skills/backend/utilities.md +1 -1
  9. package/docs/ai/skills/frontend/forms.md +23 -1
  10. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  11. package/docs/backend/04-routing/09-websocket.md +22 -1
  12. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  13. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  14. package/docs/backend/10-authentication/05-session-management.md +12 -3
  15. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  16. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  17. package/docs/index.json +1 -1
  18. package/package.json +1 -1
  19. package/src/Auth.js +17 -0
  20. package/src/Database/Migration.js +219 -3
  21. package/src/Database/ReadCache.js +174 -0
  22. package/src/Database.js +63 -0
  23. package/src/Validator.js +1 -1
  24. package/src/Var.js +1 -0
  25. package/src/WebSocket.js +80 -23
  26. package/test/Database/Migration/migrate_column.test.js +168 -0
  27. package/test/Database/ReadCache/crossTable.test.js +179 -0
  28. package/test/Database/ReadCache/get.test.js +128 -0
  29. package/test/Database/ReadCache/invalidate.test.js +103 -0
  30. package/test/Database/ReadCache/proxy.test.js +184 -0
  31. package/test/Database/insert.test.js +98 -0
  32. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  33. package/test/WebSocket/Client/limits.test.js +10 -4
  34. package/test/WebSocket/Client/readyState.test.js +154 -0
  35. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
@@ -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()
package/src/Database.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
  const {buildConnections} = require('./Database/ConnectionFactory')
3
3
  const nanoid = require('./Database/nanoid')
4
+ const readCache = require('./Database/ReadCache')
4
5
  const writeBuffer = require('./Database/WriteBuffer')
5
6
 
6
7
  class DatabaseManager {
@@ -33,6 +34,9 @@ class DatabaseManager {
33
34
  // Runs on ALL processes (primary + workers) since every process may insert data.
34
35
  this._loadNanoidMeta()
35
36
 
37
+ // Initialize Read-Through Cache (all processes — every worker may read cached data)
38
+ readCache.init()
39
+
36
40
  // Initialize Write-Behind Cache (Primary holds state, Workers communicate via IPC)
37
41
  await writeBuffer.init(this.connections)
38
42
  }
@@ -200,6 +204,54 @@ const tableProxyHandler = {
200
204
  flush: () => writeBuffer.flush(connectionKey, prop)
201
205
  }
202
206
 
207
+ // Read-Through Cache: Odac.DB.posts.cache(60).where({active: true}).select('id', 'title')
208
+ // Odac.DB.posts.cache().where({id: 5}).first()
209
+ // Odac.DB.posts.cache.clear()
210
+ // Why: Returns a Proxy that intercepts .then() to inject cache lookup before DB execution.
211
+ // The ttl parameter on cache() sets per-query TTL; omit for config default.
212
+ const cacheFactory = ttl => {
213
+ const cachedQb = knexInstance(prop)
214
+ cachedQb._odacCacheTtl = ttl || 0
215
+
216
+ // Capture the ORIGINAL .then() before overriding — prevents infinite recursion
217
+ // when ReadCache.get() needs to execute the actual DB query on cache MISS.
218
+ const originalCacheThen = cachedQb.then.bind(cachedQb)
219
+ cachedQb.then = function (resolve, reject) {
220
+ const executeFn = () => new Promise((res, rej) => originalCacheThen(res, rej))
221
+ return readCache.get(connectionKey, prop, this, executeFn, this._odacCacheTtl).then(resolve, reject)
222
+ }
223
+
224
+ return cachedQb
225
+ }
226
+
227
+ qb.cache = Object.assign(cacheFactory, {
228
+ clear: () => readCache.clear(connectionKey, prop)
229
+ })
230
+
231
+ // Automatic cache invalidation on write operations (insert/update/delete/truncate).
232
+ // Why: Prevents stale reads — any mutation on a table purges all cached SELECT results.
233
+ // Write methods are terminal (no further chaining after await), so wrapping the return
234
+ // as a simple thenable is safe and avoids .then() override conflicts with count/other wraps.
235
+ const wrapWithInvalidation = original =>
236
+ function (...args) {
237
+ const qbResult = original.apply(this, args)
238
+ const thenable = {
239
+ then: (resolve, reject) =>
240
+ qbResult.then(res => readCache.invalidate(connectionKey, prop).then(() => res), reject).then(resolve, reject),
241
+ catch: fn => thenable.then(undefined, fn)
242
+ }
243
+ return thenable
244
+ }
245
+
246
+ const originalUpdate = qb.update
247
+ const originalDelete = qb.delete
248
+ const originalDel = qb.del
249
+ const originalTruncate = qb.truncate
250
+ qb.update = wrapWithInvalidation(originalUpdate)
251
+ qb.delete = wrapWithInvalidation(originalDelete)
252
+ qb.del = wrapWithInvalidation(originalDel)
253
+ qb.truncate = wrapWithInvalidation(originalTruncate)
254
+
203
255
  // Odac DX Improvement: Wrap count() to return a clean number
204
256
  const originalCount = qb.count
205
257
  qb.count = function (...args) {
@@ -228,6 +280,10 @@ const tableProxyHandler = {
228
280
  }
229
281
  }
230
282
 
283
+ // Cache invalidation for insert — applied AFTER nanoid wrap so both paths are covered.
284
+ const currentInsert = qb.insert
285
+ qb.insert = wrapWithInvalidation(currentInsert)
286
+
231
287
  const originalThen = qb.then
232
288
  qb.then = function (resolve, reject) {
233
289
  if (this._odacIsCount) {
@@ -288,6 +344,13 @@ const rootProxy = new Proxy(manager, {
288
344
  }
289
345
  }
290
346
 
347
+ // Global ReadCache: Odac.DB.cache.clear(connection, table)
348
+ if (prop === 'cache') {
349
+ return {
350
+ clear: (connection, table) => readCache.clear(connection, table)
351
+ }
352
+ }
353
+
291
354
  // Access to specific database connection: Odac.DB.analytics
292
355
  if (target.connections[prop]) {
293
356
  return new Proxy(target.connections[prop], tableProxyHandler)
package/src/Validator.js CHANGED
@@ -224,7 +224,7 @@ class Validator {
224
224
  error = value && value !== '' && !this.#odac.Var(value).is('url')
225
225
  break
226
226
  case 'username':
227
- error = value && value !== '' && !/^[a-zA-Z0-9]+$/.test(value)
227
+ error = value && value !== '' && !this.#odac.Var(value).is('username')
228
228
  break
229
229
  case 'xss':
230
230
  error = value && value !== '' && /<[^>]*>/g.test(value)
package/src/Var.js CHANGED
@@ -139,6 +139,7 @@ class Var {
139
139
  /([0-9#][\u20E3])|[\u00ae\u00a9\u203C\u2047\u2048\u2049\u3030\u303D\u2139\u2122\u3297\u3299][\uFE00-\uFEFF]?|[\u2190-\u21FF][\uFE00-\uFEFF]?|[\u2300-\u23FF][\uFE00-\uFEFF]?|[\u2460-\u24FF][\uFE00-\uFEFF]?|[\u25A0-\u25FF][\uFE00-\uFEFF]?|[\u2600-\u27BF][\uFE00-\uFEFF]?|[\u2900-\u297F][\uFE00-\uFEFF]?|[\u2B00-\u2BF0][\uFE00-\uFEFF]?|[\u1F000-\u1F6FF][\uFE00-\uFEFF]?/u.test(
140
140
  this.#value
141
141
  ))
142
+ if (args.includes('username')) result = (result || any) && ((any && result) || /^[a-zA-Z0-9]+$/.test(this.#value))
142
143
  if (args.includes('xss')) result = (result || any) && ((any && result) || this.#value == this.#value.replace(/<[^>]*>/g, ''))
143
144
  return result
144
145
  }