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.
- package/CHANGELOG.md +43 -0
- package/docs/ai/README.md +2 -1
- package/docs/ai/skills/SKILL.md +2 -1
- package/docs/ai/skills/backend/authentication.md +12 -6
- package/docs/ai/skills/backend/database.md +85 -5
- 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/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/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 +321 -10
- package/src/Database/ReadCache.js +174 -0
- package/src/Database/WriteBuffer.js +15 -1
- package/src/Database.js +78 -1
- 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 +311 -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/insert.test.js +118 -0
- package/test/Database/insert.test.js +98 -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
|
@@ -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 || []
|
|
@@ -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))
|
|
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
|
|
543
|
-
if (
|
|
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
|
|
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
|
|
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)
|