odac 1.4.8 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/docs/ai/README.md +2 -1
  3. package/docs/ai/skills/SKILL.md +2 -1
  4. package/docs/ai/skills/backend/authentication.md +12 -6
  5. package/docs/ai/skills/backend/database.md +85 -5
  6. package/docs/ai/skills/backend/migrations.md +23 -0
  7. package/docs/ai/skills/backend/odac-var.md +155 -0
  8. package/docs/ai/skills/backend/utilities.md +1 -1
  9. package/docs/ai/skills/frontend/forms.md +23 -1
  10. package/docs/backend/04-routing/09-websocket-quick-reference.md +21 -1
  11. package/docs/backend/04-routing/09-websocket.md +22 -1
  12. package/docs/backend/08-database/06-read-through-cache.md +206 -0
  13. package/docs/backend/10-authentication/01-authentication-basics.md +53 -0
  14. package/docs/backend/10-authentication/05-session-management.md +12 -3
  15. package/docs/backend/13-utilities/01-odac-var.md +13 -19
  16. package/docs/frontend/03-forms/01-form-handling.md +15 -2
  17. package/docs/index.json +1 -1
  18. package/package.json +1 -1
  19. package/src/Auth.js +17 -0
  20. package/src/Database/Migration.js +321 -10
  21. package/src/Database/ReadCache.js +174 -0
  22. package/src/Database/WriteBuffer.js +15 -1
  23. package/src/Database.js +78 -1
  24. package/src/Validator.js +1 -1
  25. package/src/Var.js +1 -0
  26. package/src/WebSocket.js +80 -23
  27. package/test/Database/Migration/migrate_column.test.js +311 -0
  28. package/test/Database/ReadCache/crossTable.test.js +179 -0
  29. package/test/Database/ReadCache/get.test.js +128 -0
  30. package/test/Database/ReadCache/invalidate.test.js +103 -0
  31. package/test/Database/ReadCache/proxy.test.js +184 -0
  32. package/test/Database/WriteBuffer/insert.test.js +118 -0
  33. package/test/Database/insert.test.js +98 -0
  34. package/test/WebSocket/Client/fragmentation.test.js +130 -0
  35. package/test/WebSocket/Client/limits.test.js +10 -4
  36. package/test/WebSocket/Client/readyState.test.js +154 -0
  37. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +0 -55
@@ -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 || []
@@ -438,7 +524,35 @@ class Migration {
438
524
  if (!currentColumns[colName]) continue // New column, handled above
439
525
 
440
526
  if (this._columnNeedsAlter(colDef, currentColumns[colName])) {
441
- ops.push({type: 'alter_column', column: colName, definition: colDef})
527
+ ops.push({type: 'alter_column', column: colName, definition: colDef, currentNullable: currentColumns[colName].nullable})
528
+ }
529
+ }
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
+ }
442
556
  }
443
557
  }
444
558
 
@@ -468,22 +582,71 @@ class Migration {
468
582
  /**
469
583
  * Checks if a column definition differs from the current DB metadata enough to warrant ALTER.
470
584
  * Conservative: only alters when there is a clear type or constraint mismatch.
585
+ * Why: Without type comparison, changing a column from e.g. 'string' to 'text' in the
586
+ * schema file would be silently ignored — the DB would never receive the ALTER.
471
587
  * @param {object} desired - Column definition from schema file
472
588
  * @param {object} current - Column metadata from introspection
473
589
  * @returns {boolean}
474
590
  */
475
591
  _columnNeedsAlter(desired, current) {
592
+ // Type mismatch — map the raw DB type back to an ODAC type and compare.
593
+ // nanoid is stored as 'string' (varchar) in the DB, so normalize before comparison.
594
+ // specificType uses the raw DB type directly (def.length holds the actual PG type),
595
+ // so compare against the raw introspected type instead of reverse-mapping.
596
+ if (desired.type === 'specificType') {
597
+ const rawDesired = (desired.length || '').toLowerCase().trim()
598
+ const rawCurrent = (current.type || '').toLowerCase().trim()
599
+ if (rawDesired !== rawCurrent) return true
600
+ } else {
601
+ const desiredType = desired.type === 'nanoid' ? 'string' : desired.type
602
+ const currentType = this._reverseMapType(current.type)
603
+ if (desiredType !== currentType) return true
604
+ }
605
+
476
606
  // Nullable mismatch
477
607
  if (desired.nullable === false && current.nullable === true) return true
478
608
  if (desired.nullable === true && current.nullable === false) return true
479
609
 
480
610
  // Length mismatch for string types — use Number() coercion since some
481
611
  // drivers (SQLite) return maxLength as a string, e.g. '100' vs 100.
482
- if (desired.length && current.maxLength && Number(desired.length) !== Number(current.maxLength)) return true
612
+ if (desired.type !== 'specificType' && desired.length && current.maxLength && Number(desired.length) !== Number(current.maxLength))
613
+ return true
614
+
615
+ // Default value mismatch — normalize both sides before comparing because
616
+ // drivers return defaults as strings (e.g. "'active'" in PG, "active" in SQLite).
617
+ const desiredDefault = desired.default !== undefined ? this._normalizeDefaultValue(desired.default) : null
618
+ const currentDefault =
619
+ current.defaultValue !== undefined && current.defaultValue !== null ? this._normalizeDefaultValue(current.defaultValue) : null
620
+
621
+ if (desiredDefault !== currentDefault) return true
483
622
 
484
623
  return false
485
624
  }
486
625
 
626
+ /**
627
+ * Normalizes a column default value to a canonical string for cross-driver comparison.
628
+ * Why: Each DB driver serializes defaults differently — PG wraps strings in single quotes
629
+ * and appends type casts (e.g. `'active'::character varying`), SQLite returns raw values,
630
+ * MySQL returns unquoted strings. Stripping quotes and casts gives a stable comparison key.
631
+ * @param {*} value - Raw default value from schema definition or DB introspection
632
+ * @returns {string} Normalized string representation
633
+ */
634
+ _normalizeDefaultValue(value) {
635
+ if (value === null || value === undefined) return 'null'
636
+
637
+ let str = String(value)
638
+
639
+ // Strip PG type cast suffix: 'foo'::character varying → 'foo'
640
+ str = str.replace(/::[\w\s]+$/, '')
641
+
642
+ // Strip surrounding single quotes added by PG/MySQL: 'foo' → foo
643
+ if (str.startsWith("'") && str.endsWith("'")) {
644
+ str = str.slice(1, -1)
645
+ }
646
+
647
+ return str.trim().toLowerCase()
648
+ }
649
+
487
650
  /**
488
651
  * Generates a deterministic signature for an index to enable set comparison.
489
652
  * @param {object} idx - Index definition {columns, unique}
@@ -538,11 +701,19 @@ class Migration {
538
701
  async _applyDiff(knex, tableName, diff) {
539
702
  const columnOps = diff.filter(op => op.type === 'add_column' || op.type === 'drop_column' || op.type === 'alter_column')
540
703
  const indexOps = diff.filter(op => op.type === 'add_index' || op.type === 'drop_index')
704
+ const fkOps = diff.filter(op => op.type === 'add_foreign_key' || op.type === 'drop_foreign_key')
705
+
706
+ // Separate primary key alter ops — PostgreSQL's ALTER COLUMN via Knex emits
707
+ // DROP NOT NULL before SET NOT NULL, which PG rejects on PK columns (42P16).
708
+ // These must be handled with raw ALTER COLUMN ... TYPE ... USING instead.
709
+ const isPG = knex.client?.config?.client === 'pg' || knex.client?.config?.client === 'postgresql'
710
+ const pkAlterOps = isPG ? columnOps.filter(op => op.type === 'alter_column' && op.definition.primary) : []
711
+ const batchOps = isPG ? columnOps.filter(op => !(op.type === 'alter_column' && op.definition.primary)) : columnOps
541
712
 
542
- // Phase 1: Column operations atomic batch
543
- if (columnOps.length > 0) {
713
+ // Phase 1a: Batch column operations (non-PK alters + adds + drops)
714
+ if (batchOps.length > 0) {
544
715
  await knex.schema.alterTable(tableName, table => {
545
- for (const op of columnOps) {
716
+ for (const op of batchOps) {
546
717
  switch (op.type) {
547
718
  case 'add_column':
548
719
  this._addColumn(table, op.column, op.definition)
@@ -551,19 +722,94 @@ class Migration {
551
722
  table.dropColumn(op.column)
552
723
  break
553
724
  case 'alter_column':
554
- this._alterColumn(table, op.column, op.definition)
725
+ this._alterColumn(table, op.column, op.definition, op.currentNullable)
555
726
  break
556
727
  }
557
728
  }
558
729
  })
559
730
  }
560
731
 
561
- // Phase 2: Index operations each applied individually for idempotent safety
732
+ // Phase 1b: Primary key column type changes on PostgreSQL raw SQL.
733
+ // Why: Knex .alter() generates "DROP NOT NULL" + "SET NOT NULL" sequence,
734
+ // but PG forbids DROP NOT NULL on primary key columns. Raw ALTER COLUMN TYPE
735
+ // changes the type without touching the NOT NULL constraint.
736
+ for (const op of pkAlterOps) {
737
+ const sqlType = this._pgColumnType(op.definition)
738
+ await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? TYPE ${sqlType} USING ??::${sqlType}`, [tableName, op.column, op.column])
739
+
740
+ // Apply default value change if specified
741
+ if (op.definition.default !== undefined) {
742
+ if (op.definition.default === 'now()') {
743
+ await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT now()`, [tableName, op.column])
744
+ } else {
745
+ await knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? SET DEFAULT ?`, [tableName, op.column, op.definition.default])
746
+ }
747
+ }
748
+ }
749
+
750
+ // Phase 2: Foreign key operations — drop before add to handle replacements
751
+ for (const op of fkOps) {
752
+ if (op.type === 'drop_foreign_key') {
753
+ await this._applyForeignKeyOp(knex, tableName, op)
754
+ }
755
+ }
756
+ for (const op of fkOps) {
757
+ if (op.type === 'add_foreign_key') {
758
+ await this._applyForeignKeyOp(knex, tableName, op)
759
+ }
760
+ }
761
+
762
+ // Phase 3: Index operations — each applied individually for idempotent safety
562
763
  for (const op of indexOps) {
563
764
  await this._applyIndexOp(knex, tableName, op)
564
765
  }
565
766
  }
566
767
 
768
+ /**
769
+ * Maps an ODAC column definition to a PostgreSQL type string for raw ALTER COLUMN TYPE.
770
+ * @param {object} def - Column definition from schema
771
+ * @returns {string} PostgreSQL type name
772
+ */
773
+ _pgColumnType(def) {
774
+ switch (def.type) {
775
+ case 'nanoid':
776
+ case 'string':
777
+ return `varchar(${def.length || (def.type === 'nanoid' ? 21 : 255)})`
778
+ case 'text':
779
+ return 'text'
780
+ case 'integer':
781
+ return 'integer'
782
+ case 'bigInteger':
783
+ return 'bigint'
784
+ case 'boolean':
785
+ return 'boolean'
786
+ case 'float':
787
+ return 'double precision'
788
+ case 'decimal':
789
+ return `numeric(${def.precision || 10},${def.scale || 2})`
790
+ case 'uuid':
791
+ return 'uuid'
792
+ case 'json':
793
+ return 'json'
794
+ case 'jsonb':
795
+ return 'jsonb'
796
+ case 'timestamp':
797
+ return 'timestamp'
798
+ case 'datetime':
799
+ return 'timestamp'
800
+ case 'date':
801
+ return 'date'
802
+ case 'time':
803
+ return 'time'
804
+ case 'binary':
805
+ return 'bytea'
806
+ case 'specificType':
807
+ return def.length || def.specificType || def.type
808
+ default:
809
+ return def.type
810
+ }
811
+ }
812
+
567
813
  /**
568
814
  * Why: PostgreSQL introspection can miss existing constraints across PG versions
569
815
  * (int2vector cast edge cases, search_path mismatches, expression indexes).
@@ -606,6 +852,63 @@ class Migration {
606
852
  }
607
853
  }
608
854
 
855
+ /**
856
+ * Applies a single foreign key add/drop operation with idempotent error handling.
857
+ * Why: Knex's col.alter() cannot manage FK constraints — they require table-level
858
+ * alterTable calls (add .foreign() / drop .dropForeign()) which are separate from column ops.
859
+ * @param {object} knex - Knex instance
860
+ * @param {string} tableName - Target table
861
+ * @param {object} op - FK operation {type, column, definition?, constraintName?}
862
+ */
863
+ async _applyForeignKeyOp(knex, tableName, op) {
864
+ try {
865
+ if (op.type === 'add_foreign_key') {
866
+ const ref = op.definition.references
867
+
868
+ // Clean orphan rows before adding constraint — existing data may reference
869
+ // rows that no longer exist in the parent table, which would cause PG error 23503.
870
+ // Nullable columns get SET NULL safely. Non-nullable columns are NOT deleted —
871
+ // instead the constraint is skipped with a warning to prevent silent data loss.
872
+ const orphanCondition = knex(tableName).whereNotIn(op.column, knex(ref.table).select(ref.column)).whereNotNull(op.column)
873
+
874
+ if (op.definition.nullable !== false) {
875
+ await orphanCondition.update({[op.column]: null})
876
+ } else {
877
+ const [{count: orphanCount}] = await orphanCondition.clone().count('* as count')
878
+
879
+ if (Number(orphanCount) > 0) {
880
+ console.error(
881
+ `\x1b[31m[ODAC Migration]\x1b[0m Skipping foreign key on "${tableName}.${op.column}" → ` +
882
+ `"${ref.table}.${ref.column}": ${orphanCount} orphan row(s) found. ` +
883
+ `Column is NOT NULL so rows cannot be nullified. ` +
884
+ `Clean the data manually and restart, or make the column nullable.`
885
+ )
886
+ return
887
+ }
888
+ }
889
+
890
+ await knex.schema.alterTable(tableName, table => {
891
+ const fk = table.foreign(op.column).references(ref.column).inTable(ref.table)
892
+ if (op.definition.onDelete) fk.onDelete(op.definition.onDelete)
893
+ if (op.definition.onUpdate) fk.onUpdate(op.definition.onUpdate)
894
+ })
895
+ } else if (op.type === 'drop_foreign_key') {
896
+ await knex.schema.alterTable(tableName, table => {
897
+ table.dropForeign(op.column)
898
+ })
899
+ }
900
+ } catch (e) {
901
+ const isDuplicate = e.message && (e.message.includes('already exists') || e.code === '42710')
902
+ const isNotFound = e.message && (e.message.includes('does not exist') || e.code === '42704')
903
+
904
+ if ((op.type === 'add_foreign_key' && isDuplicate) || (op.type === 'drop_foreign_key' && isNotFound)) {
905
+ return
906
+ }
907
+
908
+ throw e
909
+ }
910
+ }
911
+
609
912
  /**
610
913
  * Translates schema column definitions into Knex schema builder calls.
611
914
  * Supports all common column types with their modifiers.
@@ -686,6 +989,8 @@ class Migration {
686
989
  return table.uuid(colName)
687
990
  case 'enum':
688
991
  return table.enum(colName, def.values || [])
992
+ case 'specificType':
993
+ return table.specificType(colName, def.length || def.specificType || def.type)
689
994
  default:
690
995
  return table.specificType(colName, def.type)
691
996
  }
@@ -734,12 +1039,18 @@ class Migration {
734
1039
  * @param {string} colName - Column name
735
1040
  * @param {object} def - Column definition
736
1041
  */
737
- _alterColumn(table, colName, def) {
1042
+ _alterColumn(table, colName, def, currentNullable) {
738
1043
  const col = this._createColumnBuilder(table, colName, def)
739
1044
  if (!col) return
740
1045
 
1046
+ // Knex .alter() defaults to nullable when no explicit nullable/notNullable is set,
1047
+ // which generates "ALTER COLUMN ... DROP NOT NULL" — PostgreSQL rejects this on
1048
+ // primary key columns (error 42P16). When the schema doesn't specify nullable,
1049
+ // preserve the column's current DB state to avoid destructive no-op alterations.
741
1050
  if (def.nullable === false) col.notNullable()
742
1051
  else if (def.nullable === true) col.nullable()
1052
+ else if (currentNullable === false) col.notNullable()
1053
+ else if (currentNullable === true) col.nullable()
743
1054
 
744
1055
  if (def.default !== undefined) col.defaultTo(def.default)
745
1056
 
@@ -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()
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
  const cluster = require('node:cluster')
3
+ const nanoid = require('./nanoid')
3
4
 
4
5
  /**
5
6
  * Write-Behind Cache with Write Coalescing for ODAC Database layer.
@@ -56,12 +57,15 @@ class WriteBuffer {
56
57
  * Why: Initializes the WriteBuffer. Called from Database.init() after Ipc is ready.
57
58
  * Primary: recovers LMDB checkpoint, starts flush/checkpoint timers.
58
59
  * All processes: stores connection references for flush DB writes.
60
+ * @param {object} connections - Knex connection map {connectionKey: knexInstance}
61
+ * @param {object} nanoidColumns - NanoID column metadata from DatabaseManager {connectionKey: {tableName: [{column, size}]}}
59
62
  */
60
- async init(connections) {
63
+ async init(connections, nanoidColumns = {}) {
61
64
  if (this._initialized) return
62
65
  this._initialized = true
63
66
 
64
67
  this._connections = connections
68
+ this._nanoidColumns = nanoidColumns
65
69
  this._config = {...DEFAULT_CONFIG, ...Odac.Config.buffer}
66
70
 
67
71
  if (cluster.isPrimary) {
@@ -127,6 +131,16 @@ class WriteBuffer {
127
131
  * that are drained to the database in a single INSERT batch.
128
132
  */
129
133
  async insert(connection, table, row) {
134
+ // Auto-generate nanoid values for columns defined as type 'nanoid' in schema.
135
+ // Why: The Database.js proxy nanoid injection only covers direct QB calls.
136
+ // WriteBuffer bypasses that proxy — rows must be populated here before queuing.
137
+ const nanoidCols = this._nanoidColumns?.[connection]?.[table]
138
+ if (nanoidCols) {
139
+ for (const {column, size} of nanoidCols) {
140
+ if (!row[column]) row[column] = nanoid(size)
141
+ }
142
+ }
143
+
130
144
  const queueKey = `${connection}:${table}`
131
145
  const length = await Odac.Ipc.rpush(`wb:q:${queueKey}`, row)
132
146
  await Odac.Ipc.sadd('wb:idx:queues', queueKey)