odac 1.4.0 → 1.4.1

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 (40) hide show
  1. package/.agent/rules/memory.md +3 -0
  2. package/.github/workflows/release.yml +1 -1
  3. package/CHANGELOG.md +26 -0
  4. package/README.md +10 -0
  5. package/bin/odac.js +190 -0
  6. package/docs/ai/skills/SKILL.md +4 -3
  7. package/docs/ai/skills/backend/authentication.md +7 -0
  8. package/docs/ai/skills/backend/config.md +7 -0
  9. package/docs/ai/skills/backend/controllers.md +7 -0
  10. package/docs/ai/skills/backend/cron.md +9 -2
  11. package/docs/ai/skills/backend/database.md +18 -2
  12. package/docs/ai/skills/backend/forms.md +8 -1
  13. package/docs/ai/skills/backend/ipc.md +7 -0
  14. package/docs/ai/skills/backend/mail.md +7 -0
  15. package/docs/ai/skills/backend/migrations.md +80 -0
  16. package/docs/ai/skills/backend/request_response.md +7 -0
  17. package/docs/ai/skills/backend/routing.md +7 -0
  18. package/docs/ai/skills/backend/storage.md +7 -0
  19. package/docs/ai/skills/backend/streaming.md +7 -0
  20. package/docs/ai/skills/backend/structure.md +8 -1
  21. package/docs/ai/skills/backend/translations.md +7 -0
  22. package/docs/ai/skills/backend/utilities.md +7 -0
  23. package/docs/ai/skills/backend/validation.md +7 -0
  24. package/docs/ai/skills/backend/views.md +7 -0
  25. package/docs/ai/skills/frontend/core.md +7 -0
  26. package/docs/ai/skills/frontend/forms.md +7 -0
  27. package/docs/ai/skills/frontend/navigation.md +7 -0
  28. package/docs/ai/skills/frontend/realtime.md +7 -0
  29. package/docs/backend/08-database/04-migrations.md +258 -37
  30. package/package.json +1 -1
  31. package/src/Auth.js +70 -44
  32. package/src/Config.js +1 -1
  33. package/src/Database/ConnectionFactory.js +69 -0
  34. package/src/Database/Migration.js +1203 -0
  35. package/src/Database.js +35 -35
  36. package/template/schema/users.js +23 -0
  37. package/test/Auth.test.js +64 -3
  38. package/test/Config.test.js +7 -0
  39. package/test/Database/ConnectionFactory.test.js +80 -0
  40. package/test/Migration.test.js +943 -0
@@ -0,0 +1,1203 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+
6
+ /**
7
+ * ODAC Migration Engine — "Schema-First with Auto-Diff"
8
+ *
9
+ * Why: AI agents and developers need a single source of truth for database state.
10
+ * Instead of scanning hundreds of migration files, read `schema/` to know the final state.
11
+ * The engine diffs desired state vs current DB state and applies changes automatically.
12
+ */
13
+ class Migration {
14
+ constructor() {
15
+ this.schemaDir = null
16
+ this.migrationDir = null
17
+ this.connections = null
18
+ this.trackingTable = '_odac_migrations'
19
+ }
20
+
21
+ /**
22
+ * Initializes the migration engine with the project directory context.
23
+ * @param {string} projectDir - Absolute path to the project root
24
+ * @param {object} connections - DatabaseManager.connections map
25
+ */
26
+ init(projectDir, connections) {
27
+ this.schemaDir = path.join(projectDir, 'schema')
28
+ this.migrationDir = path.join(projectDir, 'migration')
29
+ this.connections = connections
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // PUBLIC API
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Runs all pending migrations: schema diff + imperative migration files + seeds.
38
+ * @param {object} options
39
+ * @param {string} [options.db] - Target a specific connection key (default: all)
40
+ * @param {boolean} [options.dryRun=false] - Only show changes, don't apply
41
+ * @returns {Promise<object>} Summary of applied changes per connection
42
+ */
43
+ async migrate(options = {}) {
44
+ const targetDb = options.db || null
45
+ const dryRun = options.dryRun || false
46
+ const summary = {}
47
+
48
+ const connectionKeys = targetDb ? [targetDb] : Object.keys(this.connections)
49
+
50
+ for (const key of connectionKeys) {
51
+ const knex = this.connections[key]
52
+ if (!knex) throw new Error(`ODAC Migration: Unknown database connection '${key}'.`)
53
+
54
+ await this._ensureTrackingTable(knex)
55
+
56
+ const schemaChanges = await this._applySchemaChanges(knex, key, dryRun)
57
+ const fileChanges = await this._applyMigrationFiles(knex, key, dryRun)
58
+ const seedChanges = await this._applySeeds(knex, key, dryRun)
59
+
60
+ summary[key] = {schema: schemaChanges, files: fileChanges, seeds: seedChanges}
61
+ }
62
+
63
+ return summary
64
+ }
65
+
66
+ /**
67
+ * Shows pending changes without applying them.
68
+ * @param {object} options
69
+ * @param {string} [options.db] - Target a specific connection key
70
+ * @returns {Promise<object>} Pending changes per connection
71
+ */
72
+ async status(options = {}) {
73
+ return this.migrate({...options, dryRun: true})
74
+ }
75
+
76
+ /**
77
+ * Rolls back the last batch of imperative migration files.
78
+ * Schema changes are NOT rolled back (use schema files to revert).
79
+ * @param {object} options
80
+ * @param {string} [options.db] - Target a specific connection key
81
+ * @returns {Promise<object>} Rolled-back migrations per connection
82
+ */
83
+ async rollback(options = {}) {
84
+ const targetDb = options.db || null
85
+ const result = {}
86
+
87
+ const connectionKeys = targetDb ? [targetDb] : Object.keys(this.connections)
88
+
89
+ for (const key of connectionKeys) {
90
+ const knex = this.connections[key]
91
+ if (!knex) throw new Error(`ODAC Migration: Unknown database connection '${key}'.`)
92
+
93
+ await this._ensureTrackingTable(knex)
94
+ result[key] = await this._rollbackLastBatch(knex, key)
95
+ }
96
+
97
+ return result
98
+ }
99
+
100
+ /**
101
+ * Reverse-engineers the current database into schema/ files.
102
+ * @param {object} options
103
+ * @param {string} [options.db] - Target a specific connection key
104
+ * @returns {Promise<object>} Generated file paths per connection
105
+ */
106
+ async snapshot(options = {}) {
107
+ const targetDb = options.db || null
108
+ const result = {}
109
+
110
+ const connectionKeys = targetDb ? [targetDb] : Object.keys(this.connections)
111
+
112
+ for (const key of connectionKeys) {
113
+ const knex = this.connections[key]
114
+ if (!knex) throw new Error(`ODAC Migration: Unknown database connection '${key}'.`)
115
+
116
+ result[key] = await this._snapshotDatabase(knex, key)
117
+ }
118
+
119
+ return result
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // SCHEMA DIFF PIPELINE
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Reads schema files, diffs against DB, and applies structural changes.
128
+ * @param {object} knex - Knex connection instance
129
+ * @param {string} connectionKey - Connection identifier
130
+ * @param {boolean} dryRun - If true, only compute changes
131
+ * @returns {Promise<Array>} List of applied operations
132
+ */
133
+ async _applySchemaChanges(knex, connectionKey, dryRun) {
134
+ const desiredSchemas = this._loadSchemaFiles(connectionKey)
135
+ const operations = []
136
+
137
+ for (const [tableName, desired] of Object.entries(desiredSchemas)) {
138
+ const exists = await knex.schema.hasTable(tableName)
139
+
140
+ if (!exists) {
141
+ const op = {type: 'create_table', table: tableName, columns: desired.columns, indexes: desired.indexes}
142
+ operations.push(op)
143
+
144
+ if (!dryRun) {
145
+ await this._createTable(knex, tableName, desired)
146
+ }
147
+ } else {
148
+ const currentColumns = await this._introspectColumns(knex, tableName)
149
+ const currentIndexes = await this._introspectIndexes(knex, tableName)
150
+ const diff = this._computeDiff(desired, currentColumns, currentIndexes)
151
+
152
+ if (diff.length > 0) {
153
+ operations.push(...diff.map(d => ({...d, table: tableName})))
154
+
155
+ if (!dryRun) {
156
+ await this._applyDiff(knex, tableName, diff)
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ return operations
163
+ }
164
+
165
+ /**
166
+ * Loads and parses schema definition files from the schema/ directory.
167
+ * Root-level files map to the 'default' connection.
168
+ * Subdirectories map to named connections.
169
+ * @param {string} connectionKey - Which connection to load schemas for
170
+ * @returns {object} Map of tableName -> schema definition
171
+ */
172
+ _loadSchemaFiles(connectionKey) {
173
+ const schemas = {}
174
+
175
+ if (!fs.existsSync(this.schemaDir)) return schemas
176
+
177
+ if (connectionKey === 'default') {
178
+ const files = fs.readdirSync(this.schemaDir).filter(f => f.endsWith('.js') && fs.statSync(path.join(this.schemaDir, f)).isFile())
179
+ for (const file of files) {
180
+ const tableName = path.basename(file, '.js')
181
+ const filePath = path.join(this.schemaDir, file)
182
+ schemas[tableName] = this._normalizeSchema(this._requireSchema(filePath))
183
+ }
184
+ } else {
185
+ const subDir = path.join(this.schemaDir, connectionKey)
186
+ if (!fs.existsSync(subDir)) return schemas
187
+
188
+ const files = fs.readdirSync(subDir).filter(f => f.endsWith('.js') && fs.statSync(path.join(subDir, f)).isFile())
189
+ for (const file of files) {
190
+ const tableName = path.basename(file, '.js')
191
+ const filePath = path.join(subDir, file)
192
+ schemas[tableName] = this._normalizeSchema(this._requireSchema(filePath))
193
+ }
194
+ }
195
+
196
+ return schemas
197
+ }
198
+
199
+ /**
200
+ * Why: Column-level `unique: true` creates a DB constraint during CREATE but is
201
+ * invisible to the diff engine's index comparison. This caused two bugs:
202
+ * 1. Silent constraint DROP on subsequent runs (not in desiredIndexes).
203
+ * 2. Duplicate constraint ADD if also listed explicitly in indexes array.
204
+ * Normalizing once at load time gives every downstream path (create, diff, apply)
205
+ * a single, deduplicated source of truth for indexes.
206
+ * @param {object} schema - Raw schema definition from file
207
+ * @returns {object} Schema with column-level unique constraints merged into indexes
208
+ */
209
+ _normalizeSchema(schema) {
210
+ const columns = schema.columns || {}
211
+ const indexes = [...(schema.indexes || [])]
212
+ const existingSignatures = new Set(indexes.map(idx => this._indexSignature(idx)))
213
+
214
+ for (const [colName, colDef] of Object.entries(columns)) {
215
+ if (!colDef.unique) continue
216
+ if (colDef.type === 'timestamps' || colDef.type === 'increments' || colDef.type === 'bigIncrements') continue
217
+
218
+ const implicitIdx = {columns: [colName], unique: true}
219
+ const sig = this._indexSignature(implicitIdx)
220
+
221
+ if (!existingSignatures.has(sig)) {
222
+ indexes.push(implicitIdx)
223
+ existingSignatures.add(sig)
224
+ }
225
+ }
226
+
227
+ return {...schema, indexes}
228
+ }
229
+
230
+ /**
231
+ * Loads a schema/migration file from disk without relying on require.cache.
232
+ * Why: Node's require cache (and Jest's module registry) can serve stale modules
233
+ * when files are overwritten at the same path. Reading raw source avoids this.
234
+ * @param {string} filePath - Absolute path to schema file
235
+ * @returns {object} Parsed module exports
236
+ */
237
+ _requireSchema(filePath) {
238
+ const Module = require('node:module')
239
+ const source = fs.readFileSync(filePath, 'utf8')
240
+ const m = new Module(filePath)
241
+ m.filename = filePath
242
+ m.paths = Module._nodeModulePaths(path.dirname(filePath))
243
+ m._compile(source, filePath)
244
+ return m.exports
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // INTROSPECTION — Read current DB state
249
+ // ---------------------------------------------------------------------------
250
+
251
+ /**
252
+ * Reads column metadata from the database for a given table.
253
+ * Uses knex.columnInfo() augmented with raw queries for precision.
254
+ * @param {object} knex - Knex connection instance
255
+ * @param {string} tableName - Table to introspect
256
+ * @returns {Promise<object>} Normalized column map
257
+ */
258
+ async _introspectColumns(knex, tableName) {
259
+ const info = await knex(tableName).columnInfo()
260
+ const columns = {}
261
+
262
+ for (const [colName, meta] of Object.entries(info)) {
263
+ columns[colName] = {
264
+ type: meta.type,
265
+ maxLength: meta.maxLength,
266
+ nullable: meta.nullable,
267
+ defaultValue: meta.defaultValue
268
+ }
269
+ }
270
+
271
+ return columns
272
+ }
273
+
274
+ /**
275
+ * Reads index metadata from the database for a given table.
276
+ * Supports MySQL, PostgreSQL, and SQLite.
277
+ * @param {object} knex - Knex connection instance
278
+ * @param {string} tableName - Table to introspect
279
+ * @returns {Promise<Array>} Normalized index list
280
+ */
281
+ async _introspectIndexes(knex, tableName) {
282
+ const client = knex.client.config.client
283
+
284
+ if (client === 'mysql2' || client === 'mysql') {
285
+ return this._introspectIndexesMySQL(knex, tableName)
286
+ } else if (client === 'pg') {
287
+ return this._introspectIndexesPG(knex, tableName)
288
+ } else if (client === 'sqlite3') {
289
+ return this._introspectIndexesSQLite(knex, tableName)
290
+ }
291
+
292
+ return []
293
+ }
294
+
295
+ async _introspectIndexesMySQL(knex, tableName) {
296
+ const [rows] = await knex.raw('SHOW INDEX FROM ??', [tableName])
297
+ const indexMap = {}
298
+
299
+ for (const row of rows) {
300
+ const name = row.Key_name
301
+ if (name === 'PRIMARY') continue
302
+
303
+ if (!indexMap[name]) {
304
+ indexMap[name] = {
305
+ name,
306
+ columns: [],
307
+ unique: !row.Non_unique
308
+ }
309
+ }
310
+ indexMap[name].columns.push(row.Column_name)
311
+ }
312
+
313
+ return Object.values(indexMap)
314
+ }
315
+
316
+ /**
317
+ * Why: The previous pg_class + pg_index + pg_attribute + int2vector::int[] cast
318
+ * approach broke across PostgreSQL versions and non-default search_path configs.
319
+ * pg_indexes is a stable, high-level view that works reliably across all PG
320
+ * versions (9.1+) without manual type casting or complex joins.
321
+ * We parse column names from the index definition using a regex to avoid all
322
+ * low-level catalog compatibility issues.
323
+ * @param {object} knex - Knex connection instance
324
+ * @param {string} tableName - Table to introspect
325
+ * @returns {Promise<Array>} Normalized index list
326
+ */
327
+ async _introspectIndexesPG(knex, tableName) {
328
+ const result = await knex.raw(
329
+ `
330
+ SELECT
331
+ i.relname AS index_name,
332
+ ix.indisunique AS is_unique,
333
+ array_agg(a.attname ORDER BY a.attnum) AS columns
334
+ FROM pg_index ix
335
+ JOIN pg_class t ON t.oid = ix.indrelid
336
+ JOIN pg_class i ON i.oid = ix.indexrelid
337
+ JOIN pg_namespace n ON n.oid = t.relnamespace
338
+ JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
339
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
340
+ WHERE t.relname = ?
341
+ AND n.nspname = current_schema()
342
+ AND ix.indisprimary = false
343
+ AND a.attnum > 0
344
+ GROUP BY i.relname, ix.indisunique
345
+ `,
346
+ [tableName]
347
+ )
348
+
349
+ return result.rows.map(row => ({
350
+ name: row.index_name,
351
+ columns: Array.isArray(row.columns) ? row.columns : [],
352
+ unique: !!row.is_unique
353
+ }))
354
+ }
355
+
356
+ async _introspectIndexesSQLite(knex, tableName) {
357
+ const safeTableName = this._quoteSQLiteIdentifier(tableName)
358
+ const rawIndexes = await knex.raw(`PRAGMA index_list(${safeTableName})`)
359
+ const indexes = Array.isArray(rawIndexes) ? rawIndexes : []
360
+ const result = []
361
+
362
+ for (const idx of indexes) {
363
+ if (idx.origin === 'pk') continue
364
+ // Skip auto-generated unique constraint indexes (created by Knex .unique())
365
+ // These have origin='c' but we still track them since they are user-defined
366
+
367
+ const safeIndexName = this._quoteSQLiteIdentifier(idx.name)
368
+ const rawCols = await knex.raw(`PRAGMA index_info(${safeIndexName})`)
369
+ const cols = Array.isArray(rawCols) ? rawCols : []
370
+ result.push({
371
+ name: idx.name,
372
+ columns: cols.map(c => c.name),
373
+ unique: !!idx.unique
374
+ })
375
+ }
376
+
377
+ return result
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // DIFF ENGINE — Compute desired vs current delta
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Computes the structural diff between desired schema and current DB state.
386
+ * Produces a list of atomic operations to reconcile the two.
387
+ * @param {object} desired - Schema definition from file
388
+ * @param {object} currentColumns - Introspected column map
389
+ * @param {Array} currentIndexes - Introspected index list
390
+ * @returns {Array} Ordered list of diff operations
391
+ */
392
+ _computeDiff(desired, currentColumns, currentIndexes) {
393
+ const ops = []
394
+ const desiredColumns = desired.columns || {}
395
+ const desiredIndexes = desired.indexes || []
396
+ const currentColNames = Object.keys(currentColumns)
397
+
398
+ // --- Column additions ---
399
+ for (const [colName, colDef] of Object.entries(desiredColumns)) {
400
+ if (colDef.type === 'timestamps') continue // Virtual type handled separately
401
+ if (!currentColumns[colName]) {
402
+ ops.push({type: 'add_column', column: colName, definition: colDef})
403
+ }
404
+ }
405
+
406
+ // Handle timestamps virtual type
407
+ if (this._hasTimestamps(desiredColumns)) {
408
+ if (!currentColumns['created_at']) {
409
+ ops.push({type: 'add_column', column: 'created_at', definition: {type: 'timestamp'}})
410
+ }
411
+ if (!currentColumns['updated_at']) {
412
+ ops.push({type: 'add_column', column: 'updated_at', definition: {type: 'timestamp'}})
413
+ }
414
+ }
415
+
416
+ // --- Column removals ---
417
+ const desiredColNames = new Set()
418
+ for (const [colName, colDef] of Object.entries(desiredColumns)) {
419
+ if (colDef.type === 'timestamps') {
420
+ desiredColNames.add('created_at')
421
+ desiredColNames.add('updated_at')
422
+ } else {
423
+ desiredColNames.add(colName)
424
+ }
425
+ }
426
+
427
+ for (const colName of currentColNames) {
428
+ if (!desiredColNames.has(colName)) {
429
+ ops.push({type: 'drop_column', column: colName})
430
+ }
431
+ }
432
+
433
+ // --- Column modifications ---
434
+ for (const [colName, colDef] of Object.entries(desiredColumns)) {
435
+ if (colDef.type === 'timestamps' || colDef.type === 'increments') continue
436
+ if (!currentColumns[colName]) continue // New column, handled above
437
+
438
+ if (this._columnNeedsAlter(colDef, currentColumns[colName])) {
439
+ ops.push({type: 'alter_column', column: colName, definition: colDef})
440
+ }
441
+ }
442
+
443
+ // --- Index synchronization ---
444
+ const desiredIndexSignatures = new Set(desiredIndexes.map(idx => this._indexSignature(idx)))
445
+ const currentIndexSignatures = new Set(currentIndexes.map(idx => this._indexSignature(idx)))
446
+
447
+ // Indexes to add
448
+ for (const idx of desiredIndexes) {
449
+ const sig = this._indexSignature(idx)
450
+ if (!currentIndexSignatures.has(sig)) {
451
+ ops.push({type: 'add_index', index: idx})
452
+ }
453
+ }
454
+
455
+ // Indexes to drop
456
+ for (const idx of currentIndexes) {
457
+ const sig = this._indexSignature(idx)
458
+ if (!desiredIndexSignatures.has(sig)) {
459
+ ops.push({type: 'drop_index', index: idx})
460
+ }
461
+ }
462
+
463
+ return ops
464
+ }
465
+
466
+ /**
467
+ * Checks if a column definition differs from the current DB metadata enough to warrant ALTER.
468
+ * Conservative: only alters when there is a clear type or constraint mismatch.
469
+ * @param {object} desired - Column definition from schema file
470
+ * @param {object} current - Column metadata from introspection
471
+ * @returns {boolean}
472
+ */
473
+ _columnNeedsAlter(desired, current) {
474
+ // Nullable mismatch
475
+ if (desired.nullable === false && current.nullable === true) return true
476
+ if (desired.nullable === true && current.nullable === false) return true
477
+
478
+ // Length mismatch for string types — use Number() coercion since some
479
+ // drivers (SQLite) return maxLength as a string, e.g. '100' vs 100.
480
+ if (desired.length && current.maxLength && Number(desired.length) !== Number(current.maxLength)) return true
481
+
482
+ return false
483
+ }
484
+
485
+ /**
486
+ * Generates a deterministic signature for an index to enable set comparison.
487
+ * @param {object} idx - Index definition {columns, unique}
488
+ * @returns {string} Canonical signature string
489
+ */
490
+ _indexSignature(idx) {
491
+ const cols = [...idx.columns].sort().join(',')
492
+ return `${idx.unique ? 'U' : 'I'}:${cols}`
493
+ }
494
+
495
+ /**
496
+ * Checks if the desired columns include a 'timestamps' virtual type.
497
+ * @param {object} columns - Desired column definitions
498
+ * @returns {boolean}
499
+ */
500
+ _hasTimestamps(columns) {
501
+ for (const colDef of Object.values(columns)) {
502
+ if (colDef.type === 'timestamps') return true
503
+ }
504
+ return false
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // APPLY CHANGES — Execute DDL operations
509
+ // ---------------------------------------------------------------------------
510
+
511
+ /**
512
+ * Creates a new table from a schema definition.
513
+ * @param {object} knex - Knex connection instance
514
+ * @param {string} tableName - Table name
515
+ * @param {object} schema - Full schema definition
516
+ */
517
+ async _createTable(knex, tableName, schema) {
518
+ await knex.schema.createTable(tableName, table => {
519
+ this._buildColumns(table, schema.columns)
520
+ this._buildIndexes(table, schema.indexes)
521
+ })
522
+ }
523
+
524
+ /**
525
+ * Applies a list of diff operations to an existing table.
526
+ * Why split into two phases: Knex wraps all alterTable operations into a single
527
+ * statement batch. If one index DDL fails (e.g. "already exists" due to introspection
528
+ * gaps across PG versions), the entire batch — including column changes — is aborted.
529
+ * Phase 1 handles column ops in a single alterTable. Phase 2 handles index ops
530
+ * individually with idempotent error handling so duplicate/missing index errors
531
+ * never crash the migration pipeline.
532
+ * @param {object} knex - Knex connection instance
533
+ * @param {string} tableName - Table name
534
+ * @param {Array} diff - List of operations from _computeDiff
535
+ */
536
+ async _applyDiff(knex, tableName, diff) {
537
+ const columnOps = diff.filter(op => op.type === 'add_column' || op.type === 'drop_column' || op.type === 'alter_column')
538
+ const indexOps = diff.filter(op => op.type === 'add_index' || op.type === 'drop_index')
539
+
540
+ // Phase 1: Column operations — atomic batch
541
+ if (columnOps.length > 0) {
542
+ await knex.schema.alterTable(tableName, table => {
543
+ for (const op of columnOps) {
544
+ switch (op.type) {
545
+ case 'add_column':
546
+ this._addColumn(table, op.column, op.definition)
547
+ break
548
+ case 'drop_column':
549
+ table.dropColumn(op.column)
550
+ break
551
+ case 'alter_column':
552
+ this._alterColumn(table, op.column, op.definition)
553
+ break
554
+ }
555
+ }
556
+ })
557
+ }
558
+
559
+ // Phase 2: Index operations — each applied individually for idempotent safety
560
+ for (const op of indexOps) {
561
+ await this._applyIndexOp(knex, tableName, op)
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Why: PostgreSQL introspection can miss existing constraints across PG versions
567
+ * (int2vector cast edge cases, search_path mismatches, expression indexes).
568
+ * Rather than silently crashing the entire migration, we catch "already exists"
569
+ * (42P07) and "does not exist" (42704/3F000) errors that indicate the DB is
570
+ * already in the desired state.
571
+ * @param {object} knex - Knex connection instance
572
+ * @param {string} tableName - Table name
573
+ * @param {object} op - Single index diff operation
574
+ */
575
+ async _applyIndexOp(knex, tableName, op) {
576
+ try {
577
+ if (op.type === 'add_index') {
578
+ await knex.schema.alterTable(tableName, table => {
579
+ if (op.index.unique) {
580
+ table.unique(op.index.columns)
581
+ } else {
582
+ table.index(op.index.columns)
583
+ }
584
+ })
585
+ } else if (op.type === 'drop_index') {
586
+ await knex.schema.alterTable(tableName, table => {
587
+ if (op.index.unique) {
588
+ table.dropUnique(op.index.columns)
589
+ } else {
590
+ table.dropIndex(op.index.columns)
591
+ }
592
+ })
593
+ }
594
+ } catch (e) {
595
+ const isDuplicate = e.code === '42P07' || e.code === 'ER_DUP_KEYNAME' || (e.message && e.message.includes('already exists'))
596
+ const isNotFound = e.code === '42704' || e.code === '3F000' || (e.message && e.message.includes('does not exist'))
597
+
598
+ if ((op.type === 'add_index' && isDuplicate) || (op.type === 'drop_index' && isNotFound)) {
599
+ // DB is already in the desired state — safe no-op
600
+ return
601
+ }
602
+
603
+ throw e
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Translates schema column definitions into Knex schema builder calls.
609
+ * Supports all common column types with their modifiers.
610
+ * @param {object} table - Knex TableBuilder instance
611
+ * @param {object} columns - Column definition map
612
+ */
613
+ _buildColumns(table, columns) {
614
+ if (!columns) return
615
+
616
+ for (const [colName, def] of Object.entries(columns)) {
617
+ if (def.type === 'timestamps') {
618
+ table.timestamps(true, true)
619
+ continue
620
+ }
621
+
622
+ const col = this._createColumnBuilder(table, colName, def)
623
+ if (!col) continue
624
+
625
+ if (def.nullable === false) col.notNullable()
626
+ else if (def.nullable === true) col.nullable()
627
+
628
+ if (def.default !== undefined) col.defaultTo(def.default)
629
+ if (def.unsigned) col.unsigned()
630
+ // Column-level unique is handled via _normalizeSchema → _buildIndexes.
631
+ // Applying it here as well would create duplicate constraints.
632
+ if (def.primary) col.primary()
633
+ if (def.references) col.references(def.references.column).inTable(def.references.table)
634
+ if (def.onDelete) col.onDelete(def.onDelete)
635
+ if (def.onUpdate) col.onUpdate(def.onUpdate)
636
+ if (def.comment) col.comment(def.comment)
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Creates a Knex column builder call for a given type.
642
+ * @param {object} table - Knex TableBuilder
643
+ * @param {string} colName - Column name
644
+ * @param {object} def - Column definition
645
+ * @returns {object|null} Knex ColumnBuilder or null
646
+ */
647
+ _createColumnBuilder(table, colName, def) {
648
+ switch (def.type) {
649
+ case 'increments':
650
+ return table.increments(colName)
651
+ case 'bigIncrements':
652
+ return table.bigIncrements(colName)
653
+ case 'integer':
654
+ return table.integer(colName)
655
+ case 'bigInteger':
656
+ return table.bigInteger(colName)
657
+ case 'float':
658
+ return table.float(colName, def.precision, def.scale)
659
+ case 'decimal':
660
+ return table.decimal(colName, def.precision || 10, def.scale || 2)
661
+ case 'string':
662
+ return table.string(colName, def.length || 255)
663
+ case 'text':
664
+ return table.text(colName, def.textType || 'text')
665
+ case 'boolean':
666
+ return table.boolean(colName)
667
+ case 'date':
668
+ return table.date(colName)
669
+ case 'datetime':
670
+ return table.datetime(colName)
671
+ case 'timestamp':
672
+ return table.timestamp(colName)
673
+ case 'time':
674
+ return table.time(colName)
675
+ case 'binary':
676
+ return table.binary(colName, def.length)
677
+ case 'json':
678
+ return table.json(colName)
679
+ case 'jsonb':
680
+ return table.jsonb(colName)
681
+ case 'uuid':
682
+ return table.uuid(colName)
683
+ case 'enum':
684
+ return table.enum(colName, def.values || [])
685
+ default:
686
+ return table.specificType(colName, def.type)
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Builds index definitions on a table during creation.
692
+ * @param {object} table - Knex TableBuilder
693
+ * @param {Array} indexes - Index definition array
694
+ */
695
+ _buildIndexes(table, indexes) {
696
+ if (!indexes || !Array.isArray(indexes)) return
697
+
698
+ for (const idx of indexes) {
699
+ if (idx.unique) {
700
+ table.unique(idx.columns)
701
+ } else {
702
+ table.index(idx.columns)
703
+ }
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Adds a single column to an existing table via ALTER.
709
+ * @param {object} table - Knex TableBuilder (alter context)
710
+ * @param {string} colName - Column name
711
+ * @param {object} def - Column definition
712
+ */
713
+ _addColumn(table, colName, def) {
714
+ const col = this._createColumnBuilder(table, colName, def)
715
+ if (!col) return
716
+
717
+ if (def.nullable === false) col.notNullable()
718
+ else col.nullable()
719
+
720
+ if (def.default !== undefined) col.defaultTo(def.default)
721
+ if (def.unsigned) col.unsigned()
722
+ if (def.references) col.references(def.references.column).inTable(def.references.table)
723
+ if (def.onDelete) col.onDelete(def.onDelete)
724
+ if (def.onUpdate) col.onUpdate(def.onUpdate)
725
+ }
726
+
727
+ /**
728
+ * Alters an existing column to match the desired definition.
729
+ * @param {object} table - Knex TableBuilder (alter context)
730
+ * @param {string} colName - Column name
731
+ * @param {object} def - Column definition
732
+ */
733
+ _alterColumn(table, colName, def) {
734
+ const col = this._createColumnBuilder(table, colName, def)
735
+ if (!col) return
736
+
737
+ if (def.nullable === false) col.notNullable()
738
+ else if (def.nullable === true) col.nullable()
739
+
740
+ if (def.default !== undefined) col.defaultTo(def.default)
741
+
742
+ col.alter()
743
+ }
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // IMPERATIVE MIGRATION FILES
747
+ // ---------------------------------------------------------------------------
748
+
749
+ /**
750
+ * Runs pending imperative migration files (developer-written data migrations).
751
+ * @param {object} knex - Knex connection instance
752
+ * @param {string} connectionKey - Connection identifier
753
+ * @param {boolean} dryRun - If true, only list pending files
754
+ * @returns {Promise<Array>} Applied migration file names
755
+ */
756
+ async _applyMigrationFiles(knex, connectionKey, dryRun) {
757
+ const migrationFiles = this._loadMigrationFiles(connectionKey)
758
+ if (migrationFiles.length === 0) return []
759
+
760
+ const applied = await knex(this.trackingTable).where('connection', connectionKey).where('type', 'file').select('name')
761
+
762
+ const appliedNames = new Set(applied.map(r => r.name))
763
+ const pending = migrationFiles.filter(f => !appliedNames.has(f.name))
764
+
765
+ if (dryRun) {
766
+ return pending.map(f => ({type: 'pending_file', name: f.name}))
767
+ }
768
+
769
+ // Determine the next batch number
770
+ const lastBatch = await knex(this.trackingTable).where('connection', connectionKey).max('batch as maxBatch').first()
771
+ const batch = (lastBatch?.maxBatch || 0) + 1
772
+
773
+ const results = []
774
+
775
+ for (const file of pending) {
776
+ const migration = this._requireSchema(file.path)
777
+
778
+ if (typeof migration.up !== 'function') {
779
+ throw new Error(`ODAC Migration: File '${file.name}' is missing an 'up' function.`)
780
+ }
781
+
782
+ await migration.up(knex)
783
+
784
+ await knex(this.trackingTable).insert({
785
+ name: file.name,
786
+ connection: connectionKey,
787
+ type: 'file',
788
+ batch,
789
+ applied_at: new Date()
790
+ })
791
+
792
+ results.push({type: 'applied_file', name: file.name})
793
+ }
794
+
795
+ return results
796
+ }
797
+
798
+ /**
799
+ * Loads imperative migration files sorted by filename (timestamp order).
800
+ * @param {string} connectionKey - Connection identifier
801
+ * @returns {Array<{name: string, path: string}>} Sorted migration file descriptors
802
+ */
803
+ _loadMigrationFiles(connectionKey) {
804
+ let dir
805
+
806
+ if (connectionKey === 'default') {
807
+ dir = this.migrationDir
808
+ } else {
809
+ dir = path.join(this.migrationDir, connectionKey)
810
+ }
811
+
812
+ if (!fs.existsSync(dir)) return []
813
+
814
+ return fs
815
+ .readdirSync(dir)
816
+ .filter(f => f.endsWith('.js') && !fs.statSync(path.join(dir, f)).isDirectory())
817
+ .sort()
818
+ .map(f => ({name: f, path: path.join(dir, f)}))
819
+ }
820
+
821
+ /**
822
+ * Rolls back the last batch of imperative migration files.
823
+ * @param {object} knex - Knex connection instance
824
+ * @param {string} connectionKey - Connection identifier
825
+ * @returns {Promise<Array>} Rolled-back migration names
826
+ */
827
+ async _rollbackLastBatch(knex, connectionKey) {
828
+ const lastBatch = await knex(this.trackingTable)
829
+ .where('connection', connectionKey)
830
+ .where('type', 'file')
831
+ .max('batch as maxBatch')
832
+ .first()
833
+
834
+ if (!lastBatch?.maxBatch) return []
835
+
836
+ const migrations = await knex(this.trackingTable)
837
+ .where('connection', connectionKey)
838
+ .where('type', 'file')
839
+ .where('batch', lastBatch.maxBatch)
840
+ .orderBy('name', 'desc')
841
+ .select('name')
842
+
843
+ const results = []
844
+
845
+ for (const row of migrations) {
846
+ const filePath = this._resolveMigrationFilePath(connectionKey, row.name)
847
+ if (!filePath) continue
848
+
849
+ const migration = this._requireSchema(filePath)
850
+
851
+ if (typeof migration.down === 'function') {
852
+ await migration.down(knex)
853
+ }
854
+
855
+ await knex(this.trackingTable).where('connection', connectionKey).where('name', row.name).where('type', 'file').del()
856
+
857
+ results.push({type: 'rolled_back', name: row.name})
858
+ }
859
+
860
+ return results
861
+ }
862
+
863
+ /**
864
+ * Resolves the absolute file path for a migration file by name.
865
+ * @param {string} connectionKey - Connection identifier
866
+ * @param {string} name - Migration file name (e.g. '20260225_001_auto.js')
867
+ * @returns {string|null} Absolute path or null if not found
868
+ */
869
+ _resolveMigrationFilePath(connectionKey, name) {
870
+ const dir = connectionKey === 'default' ? this.migrationDir : path.join(this.migrationDir, connectionKey)
871
+
872
+ const filePath = path.join(dir, name)
873
+ return fs.existsSync(filePath) ? filePath : null
874
+ }
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // SEED DATA
878
+ // ---------------------------------------------------------------------------
879
+
880
+ /**
881
+ * Applies seed data from schema definitions using idempotent upsert logic.
882
+ * @param {object} knex - Knex connection instance
883
+ * @param {string} connectionKey - Connection identifier
884
+ * @param {boolean} dryRun - If true, only list pending seeds
885
+ * @returns {Promise<Array>} Seed operation results
886
+ */
887
+ async _applySeeds(knex, connectionKey, dryRun) {
888
+ const schemas = this._loadSchemaFiles(connectionKey)
889
+ const results = []
890
+
891
+ for (const [tableName, schema] of Object.entries(schemas)) {
892
+ if (!schema.seed || !Array.isArray(schema.seed) || schema.seed.length === 0) continue
893
+
894
+ const seedKey = schema.seedKey
895
+
896
+ if (!seedKey) {
897
+ throw new Error(`ODAC Migration: Schema '${tableName}' has seed data but no seedKey defined.`)
898
+ }
899
+
900
+ for (const row of schema.seed) {
901
+ const keyValue = row[seedKey]
902
+ if (keyValue === undefined) continue
903
+
904
+ const preparedRow = this._prepareSeedRow(row, schema)
905
+ const existing = await knex(tableName).where(seedKey, keyValue).first()
906
+
907
+ if (!existing) {
908
+ if (!dryRun) {
909
+ await knex(tableName).insert(preparedRow)
910
+ }
911
+ results.push({type: 'seed_insert', table: tableName, key: keyValue})
912
+ } else {
913
+ const needsUpdate = this._seedRowNeedsUpdate(row, existing, seedKey)
914
+
915
+ if (needsUpdate) {
916
+ if (!dryRun) {
917
+ await knex(tableName).where(seedKey, keyValue).update(preparedRow)
918
+ }
919
+ results.push({type: 'seed_update', table: tableName, key: keyValue})
920
+ }
921
+ }
922
+ }
923
+ }
924
+
925
+ return results
926
+ }
927
+
928
+ /**
929
+ * Prepares a seed row for insertion/updating by stringifying JSON columns.
930
+ * Why: Knex/pg driver converts JavaScript arrays to PostgreSQL array literals (e.g. {1,2,3})
931
+ * instead of JSON arrays (e.g. [1,2,3]). This causes "invalid input syntax for type json"
932
+ * when seeding JSONB columns with arrays. Stringifying them explicitly fixes this.
933
+ * @param {object} row - Raw seed row
934
+ * @param {object} schema - Table schema definition
935
+ * @returns {object} Prepared row
936
+ */
937
+ _prepareSeedRow(row, schema) {
938
+ const prepared = {...row}
939
+ const columns = schema.columns || {}
940
+
941
+ for (const [key, value] of Object.entries(prepared)) {
942
+ const colDef = columns[key]
943
+ if (colDef && (colDef.type === 'json' || colDef.type === 'jsonb')) {
944
+ if (value !== null && typeof value !== 'string') {
945
+ prepared[key] = JSON.stringify(value)
946
+ }
947
+ }
948
+ }
949
+
950
+ return prepared
951
+ }
952
+
953
+ /**
954
+ * Why: The previous `String()` coercion broke for JSON/JSONB columns in two ways:
955
+ * 1. `String({})` produces "[object Object]" — useless for deep comparison.
956
+ * 2. PG may return parsed objects while seeds hold raw objects — identical data
957
+ * compared as different → false-positive UPDATE → Knex double-stringifies
958
+ * the already-serialized JSON → PG throws "invalid input syntax for type json".
959
+ *
960
+ * This method normalizes both sides to canonical JSON before comparing, which
961
+ * handles: objects, arrays, numbers-as-strings (SQLite), null vs undefined,
962
+ * and Date objects.
963
+ * @param {object} seedRow - Desired seed row from schema file
964
+ * @param {object} existingRow - Current row from DB
965
+ * @param {string} seedKey - The key column to skip during comparison
966
+ * @returns {boolean} True if the DB row needs updating
967
+ */
968
+ _seedRowNeedsUpdate(seedRow, existingRow, seedKey) {
969
+ for (const key of Object.keys(seedRow)) {
970
+ if (key === seedKey) continue
971
+
972
+ const desired = seedRow[key]
973
+ const current = existingRow[key]
974
+
975
+ // Both nullish — no change
976
+ if (desired == null && current == null) continue
977
+
978
+ // One nullish, other not — changed
979
+ if (desired == null || current == null) return true
980
+
981
+ // Both primitives — numeric-safe loose comparison
982
+ if (typeof desired !== 'object' && typeof current !== 'object') {
983
+ if (String(desired) !== String(current)) return true
984
+ continue
985
+ }
986
+
987
+ // At least one side is an object/array — canonical JSON comparison
988
+ const desiredJson = typeof desired === 'string' ? desired : JSON.stringify(desired)
989
+ const currentJson = typeof current === 'string' ? current : JSON.stringify(current)
990
+
991
+ if (desiredJson !== currentJson) return true
992
+ }
993
+
994
+ return false
995
+ }
996
+
997
+ // ---------------------------------------------------------------------------
998
+ // SNAPSHOT — Reverse-engineer DB into schema files
999
+ // ---------------------------------------------------------------------------
1000
+
1001
+ /**
1002
+ * Reads the current database structure and generates schema/ files.
1003
+ * @param {object} knex - Knex connection instance
1004
+ * @param {string} connectionKey - Connection identifier
1005
+ * @returns {Promise<Array>} Generated file paths
1006
+ */
1007
+ async _snapshotDatabase(knex, connectionKey) {
1008
+ const tables = await this._listTables(knex)
1009
+ const generatedFiles = []
1010
+ const targetDir = connectionKey === 'default' ? this.schemaDir : path.join(this.schemaDir, connectionKey)
1011
+
1012
+ if (!fs.existsSync(targetDir)) {
1013
+ fs.mkdirSync(targetDir, {recursive: true})
1014
+ }
1015
+
1016
+ for (const tableName of tables) {
1017
+ if (tableName === this.trackingTable) continue
1018
+
1019
+ const columns = await this._introspectColumns(knex, tableName)
1020
+ const indexes = await this._introspectIndexes(knex, tableName)
1021
+ const schemaContent = this._generateSchemaFileContent(tableName, columns, indexes)
1022
+ const safeFileStem = this._toSafeFileStem(tableName)
1023
+ const filePath = path.resolve(targetDir, `${safeFileStem}.js`)
1024
+ const targetRoot = path.resolve(targetDir) + path.sep
1025
+
1026
+ if (!filePath.startsWith(targetRoot)) {
1027
+ throw new Error(`ODAC Migration: Unsafe snapshot path generated for table '${tableName}'.`)
1028
+ }
1029
+
1030
+ fs.writeFileSync(filePath, schemaContent, 'utf8')
1031
+ generatedFiles.push(filePath)
1032
+ }
1033
+
1034
+ return generatedFiles
1035
+ }
1036
+
1037
+ /**
1038
+ * Lists all user tables in the current database (excluding system tables).
1039
+ * @param {object} knex - Knex connection
1040
+ * @returns {Promise<string[]>} Table name list
1041
+ */
1042
+ async _listTables(knex) {
1043
+ const client = knex.client.config.client
1044
+
1045
+ if (client === 'mysql2' || client === 'mysql') {
1046
+ const [rows] = await knex.raw('SHOW TABLES')
1047
+ return rows.map(row => Object.values(row)[0])
1048
+ } else if (client === 'pg') {
1049
+ const result = await knex.raw("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
1050
+ return result.rows.map(r => r.tablename)
1051
+ } else if (client === 'sqlite3') {
1052
+ const rows = await knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
1053
+ return rows.map(r => r.name)
1054
+ }
1055
+
1056
+ return []
1057
+ }
1058
+
1059
+ /**
1060
+ * Generates a human-readable schema file from introspected metadata.
1061
+ * @param {string} tableName - Table name
1062
+ * @param {object} columns - Introspected column map
1063
+ * @param {Array} indexes - Introspected index list
1064
+ * @returns {string} JavaScript module source code
1065
+ */
1066
+ _generateSchemaFileContent(tableName, columns, indexes) {
1067
+ const lines = []
1068
+ const safeTableLabel = this._toJsLiteral(String(tableName))
1069
+ lines.push(`// Schema definition for ${safeTableLabel} — auto-generated by ODAC snapshot`)
1070
+ lines.push(`// Review and adjust types/constraints as needed before using as source of truth.`)
1071
+ lines.push(`'use strict'`)
1072
+ lines.push('')
1073
+ lines.push('module.exports = {')
1074
+ lines.push(' columns: {')
1075
+
1076
+ const colEntries = Object.entries(columns)
1077
+ for (let i = 0; i < colEntries.length; i++) {
1078
+ const [colName, meta] = colEntries[i]
1079
+ const parts = []
1080
+
1081
+ const mappedType = this._reverseMapType(meta.type)
1082
+ parts.push(`type: ${this._toJsLiteral(mappedType)}`)
1083
+
1084
+ if (meta.maxLength) {
1085
+ const parsedLength = Number(meta.maxLength)
1086
+ if (Number.isFinite(parsedLength) && parsedLength > 0) {
1087
+ parts.push(`length: ${Math.trunc(parsedLength)}`)
1088
+ }
1089
+ }
1090
+ if (meta.nullable === false) parts.push('nullable: false')
1091
+ if (meta.defaultValue !== null && meta.defaultValue !== undefined) {
1092
+ parts.push(`default: ${this._toJsLiteral(meta.defaultValue)}`)
1093
+ }
1094
+
1095
+ const comma = i < colEntries.length - 1 ? ',' : ''
1096
+ lines.push(` ${this._toObjectKey(colName)}: {${parts.join(', ')}}${comma}`)
1097
+ }
1098
+
1099
+ lines.push(' },')
1100
+ lines.push('')
1101
+
1102
+ if (indexes.length > 0) {
1103
+ lines.push(' indexes: [')
1104
+ for (let i = 0; i < indexes.length; i++) {
1105
+ const idx = indexes[i]
1106
+ const colsStr = idx.columns.map(c => this._toJsLiteral(String(c))).join(', ')
1107
+ const uniqueStr = idx.unique ? ', unique: true' : ''
1108
+ const comma = i < indexes.length - 1 ? ',' : ''
1109
+ lines.push(` {columns: [${colsStr}]${uniqueStr}}${comma}`)
1110
+ }
1111
+ lines.push(' ]')
1112
+ } else {
1113
+ lines.push(' indexes: []')
1114
+ }
1115
+
1116
+ lines.push('}')
1117
+ lines.push('')
1118
+
1119
+ return lines.join('\n')
1120
+ }
1121
+
1122
+ _toJsLiteral(value) {
1123
+ if (typeof value === 'bigint') return `${value}n`
1124
+ return JSON.stringify(value)
1125
+ }
1126
+
1127
+ _toObjectKey(key) {
1128
+ const normalized = String(key)
1129
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalized)) return normalized
1130
+ return this._toJsLiteral(normalized)
1131
+ }
1132
+
1133
+ _toSafeFileStem(name) {
1134
+ const normalized = String(name)
1135
+ .normalize('NFKC')
1136
+ .replace(/[\\/\0]/g, '_')
1137
+ .replace(/\.+/g, '.')
1138
+ .replace(/[^A-Za-z0-9._-]/g, '_')
1139
+ .replace(/^\.+/, '')
1140
+ .trim()
1141
+
1142
+ return normalized.length > 0 ? normalized : 'table'
1143
+ }
1144
+
1145
+ _quoteSQLiteIdentifier(value) {
1146
+ const normalized = String(value)
1147
+ return `"${normalized.replace(/"/g, '""')}"`
1148
+ }
1149
+
1150
+ /**
1151
+ * Maps raw database type strings back to ODAC schema type names.
1152
+ * @param {string} rawType - Database-reported type string
1153
+ * @returns {string} ODAC schema type
1154
+ */
1155
+ _reverseMapType(rawType) {
1156
+ if (!rawType) return 'string'
1157
+ const t = rawType.toLowerCase()
1158
+
1159
+ if (t.includes('int') && t.includes('auto')) return 'increments'
1160
+ if (t === 'bigint') return 'bigInteger'
1161
+ if (t.includes('int')) return 'integer'
1162
+ if (t.includes('varchar') || t.includes('character varying')) return 'string'
1163
+ if (t === 'text' || t === 'mediumtext' || t === 'longtext') return 'text'
1164
+ if (t === 'boolean' || t === 'tinyint(1)') return 'boolean'
1165
+ if (t === 'date') return 'date'
1166
+ if (t.includes('datetime')) return 'datetime'
1167
+ if (t.includes('timestamp')) return 'timestamp'
1168
+ if (t === 'time') return 'time'
1169
+ if (t.includes('decimal') || t.includes('numeric')) return 'decimal'
1170
+ if (t.includes('float') || t.includes('double') || t.includes('real')) return 'float'
1171
+ if (t === 'json' || t === 'jsonb') return t
1172
+ if (t === 'uuid') return 'uuid'
1173
+ if (t.includes('blob') || t.includes('binary') || t.includes('bytea')) return 'binary'
1174
+ if (t.includes('enum')) return 'enum'
1175
+
1176
+ return 'string'
1177
+ }
1178
+
1179
+ // ---------------------------------------------------------------------------
1180
+ // TRACKING TABLE
1181
+ // ---------------------------------------------------------------------------
1182
+
1183
+ /**
1184
+ * Ensures the migration tracking table exists in the given connection.
1185
+ * @param {object} knex - Knex connection instance
1186
+ */
1187
+ async _ensureTrackingTable(knex) {
1188
+ const exists = await knex.schema.hasTable(this.trackingTable)
1189
+ if (exists) return
1190
+
1191
+ await knex.schema.createTable(this.trackingTable, table => {
1192
+ table.increments('id')
1193
+ table.string('name').notNullable()
1194
+ table.string('connection').notNullable()
1195
+ table.string('type').notNullable() // 'file' or 'schema'
1196
+ table.integer('batch').notNullable()
1197
+ table.timestamp('applied_at').defaultTo(knex.fn.now())
1198
+ table.index(['connection', 'type'])
1199
+ })
1200
+ }
1201
+ }
1202
+
1203
+ module.exports = new Migration()