@kysera/migrations 0.3.0 → 0.4.0

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/README.md ADDED
@@ -0,0 +1,1409 @@
1
+ # @kysera/migrations
2
+
3
+ > Lightweight, type-safe database migration management for Kysera ORM with dry-run support and flexible rollback capabilities.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@kysera/migrations.svg)](https://www.npmjs.com/package/@kysera/migrations)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/)
8
+
9
+ ## 📦 Package Information
10
+
11
+ | Property | Value |
12
+ |----------|-------|
13
+ | **Package** | `@kysera/migrations` |
14
+ | **Version** | `0.3.0` |
15
+ | **Bundle Size** | 3.7 KB (minified) |
16
+ | **Dependencies** | None (peer: kysely) |
17
+ | **Test Coverage** | 24 tests, comprehensive |
18
+ | **Supported Databases** | PostgreSQL, MySQL, SQLite |
19
+ | **Type Safety** | ✅ Full TypeScript support |
20
+
21
+ ## 🎯 Features
22
+
23
+ ### Core Migration Management
24
+ - ✅ **Simple API** - Intuitive migration creation and execution
25
+ - ✅ **Type-safe** - Full TypeScript support with Kysely integration
26
+ - ✅ **State tracking** - Automatic migration history in database
27
+ - ✅ **Sequential execution** - Migrations run in order
28
+ - ✅ **Dry run mode** - Preview changes before execution
29
+
30
+ ### Advanced Features
31
+ - ✅ **Rollback support** - Roll back one or multiple migrations
32
+ - ✅ **Partial migration** - Run up to specific migration
33
+ - ✅ **Status reporting** - View executed and pending migrations
34
+ - ✅ **Error handling** - Graceful failure with detailed logging
35
+ - ✅ **Idempotent setup** - Safe to run multiple times
36
+ - ✅ **Zero dependencies** - Only Kysely as peer dependency
37
+
38
+ ## 📥 Installation
39
+
40
+ ```bash
41
+ # pnpm (recommended)
42
+ pnpm add @kysera/migrations kysely
43
+
44
+ # npm
45
+ npm install @kysera/migrations kysely
46
+
47
+ # yarn
48
+ yarn add @kysera/migrations kysely
49
+
50
+ # bun
51
+ bun add @kysera/migrations kysely
52
+ ```
53
+
54
+ ## 🚀 Quick Start
55
+
56
+ ### Basic Usage
57
+
58
+ ```typescript
59
+ import { Kysely } from 'kysely'
60
+ import { createMigrationRunner, createMigration } from '@kysera/migrations'
61
+
62
+ // Define your migrations
63
+ const migrations = [
64
+ createMigration(
65
+ '001_create_users',
66
+ async (db) => {
67
+ await db.schema
68
+ .createTable('users')
69
+ .addColumn('id', 'serial', col => col.primaryKey())
70
+ .addColumn('email', 'varchar(255)', col => col.notNull().unique())
71
+ .addColumn('name', 'varchar(255)', col => col.notNull())
72
+ .execute()
73
+ },
74
+ async (db) => {
75
+ await db.schema.dropTable('users').execute()
76
+ }
77
+ ),
78
+
79
+ createMigration(
80
+ '002_create_posts',
81
+ async (db) => {
82
+ await db.schema
83
+ .createTable('posts')
84
+ .addColumn('id', 'serial', col => col.primaryKey())
85
+ .addColumn('user_id', 'integer', col =>
86
+ col.notNull().references('users.id').onDelete('cascade')
87
+ )
88
+ .addColumn('title', 'varchar(255)', col => col.notNull())
89
+ .addColumn('content', 'text')
90
+ .execute()
91
+ },
92
+ async (db) => {
93
+ await db.schema.dropTable('posts').execute()
94
+ }
95
+ )
96
+ ]
97
+
98
+ // Create migration runner
99
+ const db = new Kysely<Database>({ /* ... */ })
100
+ const runner = createMigrationRunner(db, migrations)
101
+
102
+ // Run all pending migrations
103
+ await runner.up()
104
+ // ✅ All migrations completed successfully
105
+
106
+ // Check status
107
+ await runner.status()
108
+ // 📊 Migration Status:
109
+ // ✅ Executed: 2
110
+ // ⏳ Pending: 0
111
+
112
+ // Rollback last migration
113
+ await runner.down(1)
114
+ // ✅ Rollback completed successfully
115
+ ```
116
+
117
+ ### Dry Run Mode
118
+
119
+ ```typescript
120
+ // Preview what would happen without making changes
121
+ const runner = createMigrationRunner(db, migrations, {
122
+ dryRun: true,
123
+ logger: console.log
124
+ })
125
+
126
+ await runner.up()
127
+ // 🔍 DRY RUN - No changes will be made
128
+ // ↑ Running 001_create_users...
129
+ // ✓ 001_create_users completed
130
+ // ↑ Running 002_create_posts...
131
+ // ✓ 002_create_posts completed
132
+ ```
133
+
134
+ ## 📖 Table of Contents
135
+
136
+ - [Installation](#-installation)
137
+ - [Quick Start](#-quick-start)
138
+ - [Core Concepts](#-core-concepts)
139
+ - [Migrations](#migrations)
140
+ - [Migration Runner](#migration-runner)
141
+ - [State Tracking](#state-tracking)
142
+ - [API Reference](#-api-reference)
143
+ - [createMigration()](#createmigration)
144
+ - [createMigrationRunner()](#createmigrationrunner)
145
+ - [MigrationRunner Methods](#migrationrunner-methods)
146
+ - [setupMigrations()](#setupmigrations)
147
+ - [Migration Lifecycle](#-migration-lifecycle)
148
+ - [Advanced Usage](#-advanced-usage)
149
+ - [Partial Migrations](#partial-migrations)
150
+ - [Custom Logger](#custom-logger)
151
+ - [Migration Metadata](#migration-metadata)
152
+ - [Complex Schema Changes](#complex-schema-changes)
153
+ - [Best Practices](#-best-practices)
154
+ - [Multi-Database Support](#-multi-database-support)
155
+ - [Troubleshooting](#-troubleshooting)
156
+ - [Examples](#-examples)
157
+
158
+ ## 💡 Core Concepts
159
+
160
+ ### Migrations
161
+
162
+ A migration represents a set of database schema changes that can be applied and (optionally) reverted:
163
+
164
+ ```typescript
165
+ interface Migration {
166
+ /** Unique migration name (e.g., '001_create_users') */
167
+ name: string
168
+
169
+ /** Migration up function - creates/modifies schema */
170
+ up: (db: Kysely<any>) => Promise<void>
171
+
172
+ /** Optional migration down function - reverts changes */
173
+ down?: (db: Kysely<any>) => Promise<void>
174
+ }
175
+ ```
176
+
177
+ **Key points:**
178
+ - **name**: Must be unique, typically versioned (001_, 002_, etc.)
179
+ - **up**: Required, contains schema changes
180
+ - **down**: Optional, should revert the up changes
181
+ - Migrations run sequentially in the order they appear in the array
182
+
183
+ ### Migration Runner
184
+
185
+ The `MigrationRunner` class manages migration execution and state:
186
+
187
+ ```typescript
188
+ class MigrationRunner {
189
+ // Run all pending migrations
190
+ async up(): Promise<void>
191
+
192
+ // Rollback last N migrations
193
+ async down(steps?: number): Promise<void>
194
+
195
+ // Show migration status
196
+ async status(): Promise<MigrationStatus>
197
+
198
+ // Reset all migrations (rollback everything)
199
+ async reset(): Promise<void>
200
+
201
+ // Migrate up to specific migration
202
+ async upTo(targetName: string): Promise<void>
203
+
204
+ // Get list of executed migrations
205
+ async getExecutedMigrations(): Promise<string[]>
206
+ }
207
+ ```
208
+
209
+ ### State Tracking
210
+
211
+ Migrations are tracked in a `migrations` table:
212
+
213
+ ```sql
214
+ CREATE TABLE migrations (
215
+ name VARCHAR(255) PRIMARY KEY,
216
+ executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
217
+ );
218
+ ```
219
+
220
+ This table is automatically created when you run migrations and stores which migrations have been executed.
221
+
222
+ ## 📚 API Reference
223
+
224
+ ### createMigration()
225
+
226
+ Create a migration object with up and optional down functions.
227
+
228
+ ```typescript
229
+ function createMigration(
230
+ name: string,
231
+ up: (db: Kysely<any>) => Promise<void>,
232
+ down?: (db: Kysely<any>) => Promise<void>
233
+ ): Migration
234
+ ```
235
+
236
+ **Parameters:**
237
+ - `name` - Unique migration identifier (e.g., '001_create_users')
238
+ - `up` - Function that applies the migration
239
+ - `down` - Optional function that reverts the migration
240
+
241
+ **Returns:** Migration object
242
+
243
+ **Example:**
244
+ ```typescript
245
+ const migration = createMigration(
246
+ '001_create_users',
247
+ async (db) => {
248
+ await db.schema
249
+ .createTable('users')
250
+ .addColumn('id', 'serial', col => col.primaryKey())
251
+ .addColumn('email', 'varchar(255)', col => col.notNull().unique())
252
+ .execute()
253
+ },
254
+ async (db) => {
255
+ await db.schema.dropTable('users').execute()
256
+ }
257
+ )
258
+ ```
259
+
260
+ ### createMigrationRunner()
261
+
262
+ Create a MigrationRunner instance.
263
+
264
+ ```typescript
265
+ function createMigrationRunner(
266
+ db: Kysely<any>,
267
+ migrations: Migration[],
268
+ options?: MigrationRunnerOptions
269
+ ): MigrationRunner
270
+ ```
271
+
272
+ **Parameters:**
273
+ - `db` - Kysely database instance
274
+ - `migrations` - Array of migration objects
275
+ - `options` - Optional configuration
276
+
277
+ **Options:**
278
+ ```typescript
279
+ interface MigrationRunnerOptions {
280
+ /** Enable dry run mode (preview only, no changes) */
281
+ dryRun?: boolean
282
+
283
+ /** Custom logger function (default: console.log) */
284
+ logger?: (message: string) => void
285
+ }
286
+ ```
287
+
288
+ **Returns:** MigrationRunner instance
289
+
290
+ **Example:**
291
+ ```typescript
292
+ const runner = createMigrationRunner(db, migrations, {
293
+ dryRun: false,
294
+ logger: (msg) => console.log(`[MIGRATION] ${msg}`)
295
+ })
296
+ ```
297
+
298
+ ### MigrationRunner Methods
299
+
300
+ #### up()
301
+
302
+ Run all pending migrations sequentially.
303
+
304
+ ```typescript
305
+ async up(): Promise<void>
306
+ ```
307
+
308
+ **Behavior:**
309
+ - Creates migrations table if it doesn't exist
310
+ - Gets list of already-executed migrations
311
+ - Runs only migrations that haven't been executed
312
+ - Marks each migration as executed after successful completion
313
+ - Stops on first error (does not mark failed migration as executed)
314
+
315
+ **Example:**
316
+ ```typescript
317
+ await runner.up()
318
+ // Output:
319
+ // ✓ 001_create_users (already executed)
320
+ // ↑ Running 002_create_posts...
321
+ // ✓ 002_create_posts completed
322
+ // ✅ All migrations completed successfully
323
+ ```
324
+
325
+ #### down()
326
+
327
+ Rollback the last N migrations.
328
+
329
+ ```typescript
330
+ async down(steps?: number): Promise<void>
331
+ ```
332
+
333
+ **Parameters:**
334
+ - `steps` - Number of migrations to rollback (default: 1)
335
+
336
+ **Behavior:**
337
+ - Gets list of executed migrations
338
+ - Selects last N migrations to rollback (in reverse order)
339
+ - Calls down() method for each migration
340
+ - Removes migration from executed list after successful rollback
341
+ - Skips migrations without down() method
342
+ - Stops on first error
343
+
344
+ **Example:**
345
+ ```typescript
346
+ // Rollback last migration
347
+ await runner.down(1)
348
+
349
+ // Rollback last 3 migrations
350
+ await runner.down(3)
351
+ ```
352
+
353
+ #### status()
354
+
355
+ Display current migration status.
356
+
357
+ ```typescript
358
+ async status(): Promise<MigrationStatus>
359
+ ```
360
+
361
+ **Returns:**
362
+ ```typescript
363
+ interface MigrationStatus {
364
+ /** List of executed migration names */
365
+ executed: string[]
366
+
367
+ /** List of pending migration names */
368
+ pending: string[]
369
+ }
370
+ ```
371
+
372
+ **Example:**
373
+ ```typescript
374
+ const status = await runner.status()
375
+ console.log(`Executed: ${status.executed.length}`)
376
+ console.log(`Pending: ${status.pending.length}`)
377
+
378
+ // Output:
379
+ // 📊 Migration Status:
380
+ // ✅ Executed: 2
381
+ // ⏳ Pending: 1
382
+ //
383
+ // Executed migrations:
384
+ // ✓ 001_create_users
385
+ // ✓ 002_create_posts
386
+ //
387
+ // Pending migrations:
388
+ // - 003_add_indexes
389
+ ```
390
+
391
+ #### reset()
392
+
393
+ Rollback all migrations (dangerous!).
394
+
395
+ ```typescript
396
+ async reset(): Promise<void>
397
+ ```
398
+
399
+ **Behavior:**
400
+ - Gets count of executed migrations
401
+ - Calls down() to rollback all migrations
402
+ - Only rolls back migrations that have down() methods
403
+
404
+ **Example:**
405
+ ```typescript
406
+ await runner.reset()
407
+ // ⚠️ Resetting 3 migrations...
408
+ // ↓ Rolling back 003_add_indexes...
409
+ // ✓ 003_add_indexes rolled back
410
+ // ↓ Rolling back 002_create_posts...
411
+ // ✓ 002_create_posts rolled back
412
+ // ↓ Rolling back 001_create_users...
413
+ // ✓ 001_create_users rolled back
414
+ // ✅ All migrations reset
415
+ ```
416
+
417
+ #### upTo()
418
+
419
+ Run migrations up to (and including) a specific migration.
420
+
421
+ ```typescript
422
+ async upTo(targetName: string): Promise<void>
423
+ ```
424
+
425
+ **Parameters:**
426
+ - `targetName` - Name of the target migration
427
+
428
+ **Behavior:**
429
+ - Finds the target migration in the list
430
+ - Runs all migrations up to and including the target
431
+ - Skips already-executed migrations
432
+ - Throws error if target migration not found
433
+
434
+ **Example:**
435
+ ```typescript
436
+ await runner.upTo('002_create_posts')
437
+ // ↑ Running 001_create_users...
438
+ // ✓ 001_create_users completed
439
+ // ↑ Running 002_create_posts...
440
+ // ✓ 002_create_posts completed
441
+ // ✅ Migrated up to 002_create_posts
442
+ ```
443
+
444
+ ### setupMigrations()
445
+
446
+ Manually create the migrations tracking table.
447
+
448
+ ```typescript
449
+ async function setupMigrations(db: Kysely<any>): Promise<void>
450
+ ```
451
+
452
+ **Note:** This is called automatically by the runner, but you can call it manually if needed.
453
+
454
+ **Example:**
455
+ ```typescript
456
+ import { setupMigrations } from '@kysera/migrations'
457
+
458
+ await setupMigrations(db)
459
+ // Creates migrations table if it doesn't exist
460
+ ```
461
+
462
+ ## 🔄 Migration Lifecycle
463
+
464
+ ### Complete Migration Flow
465
+
466
+ ```
467
+ ┌─────────────────────────────────────────────────────────┐
468
+ │ 1. Create Migrations │
469
+ │ Define up/down functions │
470
+ └─────────────────────────────────────────────────────────┘
471
+
472
+ ┌─────────────────────────────────────────────────────────┐
473
+ │ 2. Initialize Runner │
474
+ │ createMigrationRunner(db, migrations, options) │
475
+ └─────────────────────────────────────────────────────────┘
476
+
477
+ ┌─────────────────────────────────────────────────────────┐
478
+ │ 3. Check Status (Optional) │
479
+ │ await runner.status() │
480
+ │ → Shows executed and pending migrations │
481
+ └─────────────────────────────────────────────────────────┘
482
+
483
+ ┌─────────────────────────────────────────────────────────┐
484
+ │ 4. Run Migrations │
485
+ │ await runner.up() │
486
+ │ → Executes pending migrations sequentially │
487
+ │ → Marks each as executed after success │
488
+ └─────────────────────────────────────────────────────────┘
489
+
490
+ ┌─────────────────────────────────────────────────────────┐
491
+ │ 5. Rollback (If Needed) │
492
+ │ await runner.down(N) │
493
+ │ → Reverts last N migrations │
494
+ │ → Removes from executed list │
495
+ └─────────────────────────────────────────────────────────┘
496
+ ```
497
+
498
+ ### Migration Execution Order
499
+
500
+ Migrations are executed in **array order**:
501
+
502
+ ```typescript
503
+ const migrations = [
504
+ createMigration('001_first', ...), // Runs first
505
+ createMigration('002_second', ...), // Runs second
506
+ createMigration('003_third', ...) // Runs third
507
+ ]
508
+ ```
509
+
510
+ **Important:** Name your migrations with numeric prefixes to ensure proper ordering:
511
+ - ✅ `001_create_users`, `002_create_posts`, `003_add_indexes`
512
+ - ❌ `create_users`, `create_posts` (no guaranteed order)
513
+
514
+ ### Error Handling
515
+
516
+ When a migration fails:
517
+ 1. Execution stops immediately
518
+ 2. Failed migration is **not** marked as executed
519
+ 3. Previous successful migrations remain executed
520
+ 4. Error is thrown for handling
521
+
522
+ ```typescript
523
+ try {
524
+ await runner.up()
525
+ } catch (error) {
526
+ console.error('Migration failed:', error)
527
+ // Handle error: notify admins, rollback, etc.
528
+ }
529
+ ```
530
+
531
+ ## 🔧 Advanced Usage
532
+
533
+ ### Partial Migrations
534
+
535
+ Run migrations incrementally:
536
+
537
+ ```typescript
538
+ const runner = createMigrationRunner(db, migrations)
539
+
540
+ // Run only the first two migrations
541
+ await runner.upTo('002_create_posts')
542
+
543
+ // Later, run the rest
544
+ await runner.up()
545
+ ```
546
+
547
+ ### Custom Logger
548
+
549
+ Integrate with your logging system:
550
+
551
+ ```typescript
552
+ import { createMigrationRunner } from '@kysera/migrations'
553
+ import { logger } from './logger'
554
+
555
+ const runner = createMigrationRunner(db, migrations, {
556
+ logger: (message) => {
557
+ logger.info('[MIGRATIONS]', message)
558
+ }
559
+ })
560
+
561
+ await runner.up()
562
+ // Logs will be sent to your logging system
563
+ ```
564
+
565
+ ### Disable Logging
566
+
567
+ ```typescript
568
+ const runner = createMigrationRunner(db, migrations, {
569
+ logger: () => {} // No-op logger
570
+ })
571
+
572
+ await runner.up() // Silent execution
573
+ ```
574
+
575
+ ### Migration Metadata
576
+
577
+ Add metadata to migrations for documentation:
578
+
579
+ ```typescript
580
+ interface MigrationWithMeta extends Migration {
581
+ /** Human-readable description */
582
+ description?: string
583
+
584
+ /** Whether this is a breaking change */
585
+ breaking?: boolean
586
+
587
+ /** Estimated duration in milliseconds */
588
+ estimatedDuration?: number
589
+ }
590
+
591
+ const migration: MigrationWithMeta = {
592
+ name: '001_create_users',
593
+ description: 'Create users table with email and name',
594
+ breaking: false,
595
+ estimatedDuration: 500,
596
+ up: async (db) => { /* ... */ },
597
+ down: async (db) => { /* ... */ }
598
+ }
599
+ ```
600
+
601
+ ### Complex Schema Changes
602
+
603
+ Handle complex migrations with transactions:
604
+
605
+ ```typescript
606
+ createMigration(
607
+ '004_complex_refactor',
608
+ async (db) => {
609
+ // Use transaction for complex operations
610
+ await db.transaction().execute(async (trx) => {
611
+ // 1. Create new table
612
+ await trx.schema
613
+ .createTable('new_users')
614
+ .addColumn('id', 'serial', col => col.primaryKey())
615
+ .addColumn('email', 'varchar(255)', col => col.notNull())
616
+ .addColumn('full_name', 'varchar(255)', col => col.notNull())
617
+ .execute()
618
+
619
+ // 2. Copy data
620
+ await trx
621
+ .insertInto('new_users')
622
+ .columns(['email', 'full_name'])
623
+ .from(
624
+ trx.selectFrom('users')
625
+ .select(['email', 'name as full_name'])
626
+ )
627
+ .execute()
628
+
629
+ // 3. Drop old table
630
+ await trx.schema.dropTable('users').execute()
631
+
632
+ // 4. Rename new table
633
+ await trx.schema.alterTable('new_users').renameTo('users').execute()
634
+ })
635
+ },
636
+ async (db) => {
637
+ // Revert complex changes
638
+ await db.transaction().execute(async (trx) => {
639
+ await trx.schema
640
+ .alterTable('users')
641
+ .renameColumn('full_name', 'name')
642
+ .execute()
643
+ })
644
+ }
645
+ )
646
+ ```
647
+
648
+ ### Data Migrations
649
+
650
+ Migrate data along with schema:
651
+
652
+ ```typescript
653
+ createMigration(
654
+ '005_migrate_user_roles',
655
+ async (db) => {
656
+ // 1. Add new column
657
+ await db.schema
658
+ .alterTable('users')
659
+ .addColumn('role', 'varchar(50)', col => col.defaultTo('user'))
660
+ .execute()
661
+
662
+ // 2. Migrate data
663
+ await db
664
+ .updateTable('users')
665
+ .set({ role: 'admin' })
666
+ .where('email', 'like', '%@admin.com')
667
+ .execute()
668
+
669
+ // 3. Make column required
670
+ await db.schema
671
+ .alterTable('users')
672
+ .alterColumn('role', col => col.setNotNull())
673
+ .execute()
674
+ },
675
+ async (db) => {
676
+ await db.schema
677
+ .alterTable('users')
678
+ .dropColumn('role')
679
+ .execute()
680
+ }
681
+ )
682
+ ```
683
+
684
+ ## ✅ Best Practices
685
+
686
+ ### 1. Always Use Numeric Prefixes
687
+
688
+ ```typescript
689
+ // ✅ Good: Clear ordering
690
+ const migrations = [
691
+ createMigration('001_create_users', ...),
692
+ createMigration('002_create_posts', ...),
693
+ createMigration('003_add_indexes', ...)
694
+ ]
695
+
696
+ // ❌ Bad: No guaranteed order
697
+ const migrations = [
698
+ createMigration('create_users', ...),
699
+ createMigration('create_posts', ...),
700
+ createMigration('add_indexes', ...)
701
+ ]
702
+ ```
703
+
704
+ ### 2. Keep Migrations Small and Focused
705
+
706
+ ```typescript
707
+ // ✅ Good: One table per migration
708
+ createMigration('001_create_users', async (db) => {
709
+ await db.schema.createTable('users')...execute()
710
+ })
711
+
712
+ createMigration('002_create_posts', async (db) => {
713
+ await db.schema.createTable('posts')...execute()
714
+ })
715
+
716
+ // ❌ Bad: Multiple tables in one migration
717
+ createMigration('001_initial_schema', async (db) => {
718
+ await db.schema.createTable('users')...execute()
719
+ await db.schema.createTable('posts')...execute()
720
+ await db.schema.createTable('comments')...execute()
721
+ })
722
+ ```
723
+
724
+ ### 3. Always Provide down() Methods
725
+
726
+ ```typescript
727
+ // ✅ Good: Reversible migration
728
+ createMigration(
729
+ '001_create_users',
730
+ async (db) => {
731
+ await db.schema.createTable('users')...execute()
732
+ },
733
+ async (db) => {
734
+ await db.schema.dropTable('users').execute()
735
+ }
736
+ )
737
+
738
+ // ⚠️ Acceptable but not ideal: No rollback
739
+ createMigration(
740
+ '002_add_column',
741
+ async (db) => {
742
+ await db.schema
743
+ .alterTable('users')
744
+ .addColumn('status', 'varchar(50)')
745
+ .execute()
746
+ }
747
+ // No down() method - can't be rolled back
748
+ )
749
+ ```
750
+
751
+ ### 4. Test Migrations Before Production
752
+
753
+ ```typescript
754
+ // Test in development
755
+ const runner = createMigrationRunner(devDb, migrations, {
756
+ logger: console.log
757
+ })
758
+
759
+ // 1. Test up
760
+ await runner.up()
761
+ // Verify schema is correct
762
+
763
+ // 2. Test down
764
+ await runner.down(1)
765
+ // Verify rollback works
766
+
767
+ // 3. Test up again
768
+ await runner.up()
769
+ // Ensure idempotency
770
+ ```
771
+
772
+ ### 5. Use Dry Run for Production
773
+
774
+ ```typescript
775
+ // Preview changes in production
776
+ const dryRunner = createMigrationRunner(db, migrations, {
777
+ dryRun: true,
778
+ logger: console.log
779
+ })
780
+
781
+ await dryRunner.up()
782
+ // Review output, ensure it's safe
783
+
784
+ // Then run for real
785
+ const runner = createMigrationRunner(db, migrations)
786
+ await runner.up()
787
+ ```
788
+
789
+ ### 6. Organize Migrations in Separate Files
790
+
791
+ ```typescript
792
+ // migrations/001_create_users.ts
793
+ export const migration_001 = createMigration(
794
+ '001_create_users',
795
+ async (db) => { /* ... */ },
796
+ async (db) => { /* ... */ }
797
+ )
798
+
799
+ // migrations/002_create_posts.ts
800
+ export const migration_002 = createMigration(
801
+ '002_create_posts',
802
+ async (db) => { /* ... */ },
803
+ async (db) => { /* ... */ }
804
+ )
805
+
806
+ // migrations/index.ts
807
+ import { migration_001 } from './001_create_users'
808
+ import { migration_002 } from './002_create_posts'
809
+
810
+ export const migrations = [
811
+ migration_001,
812
+ migration_002
813
+ ]
814
+ ```
815
+
816
+ ### 7. Handle Breaking Changes Carefully
817
+
818
+ ```typescript
819
+ // Mark breaking changes
820
+ const migration: MigrationWithMeta = {
821
+ name: '010_remove_deprecated_columns',
822
+ description: 'Remove deprecated user columns',
823
+ breaking: true, // Alert!
824
+ up: async (db) => {
825
+ // Drop columns that apps might still use
826
+ await db.schema
827
+ .alterTable('users')
828
+ .dropColumn('old_column')
829
+ .execute()
830
+ },
831
+ down: async (db) => {
832
+ // Cannot fully revert - data loss!
833
+ await db.schema
834
+ .alterTable('users')
835
+ .addColumn('old_column', 'text')
836
+ .execute()
837
+ }
838
+ }
839
+ ```
840
+
841
+ ## 🗃️ Multi-Database Support
842
+
843
+ ### PostgreSQL
844
+
845
+ ```typescript
846
+ import { Kysely, PostgresDialect } from 'kysely'
847
+ import { Pool } from 'pg'
848
+ import { createMigrationRunner, createMigration } from '@kysera/migrations'
849
+
850
+ const db = new Kysely({
851
+ dialect: new PostgresDialect({
852
+ pool: new Pool({
853
+ host: 'localhost',
854
+ database: 'mydb',
855
+ user: 'user',
856
+ password: 'password'
857
+ })
858
+ })
859
+ })
860
+
861
+ const migrations = [
862
+ createMigration(
863
+ '001_create_users',
864
+ async (db) => {
865
+ await db.schema
866
+ .createTable('users')
867
+ .addColumn('id', 'serial', col => col.primaryKey())
868
+ .addColumn('email', 'varchar(255)', col => col.notNull().unique())
869
+ .addColumn('created_at', 'timestamp', col =>
870
+ col.notNull().defaultTo(db.fn('now'))
871
+ )
872
+ .execute()
873
+
874
+ // PostgreSQL-specific: Create index
875
+ await db.schema
876
+ .createIndex('users_email_idx')
877
+ .on('users')
878
+ .column('email')
879
+ .execute()
880
+ },
881
+ async (db) => {
882
+ await db.schema.dropTable('users').cascade().execute()
883
+ }
884
+ )
885
+ ]
886
+
887
+ const runner = createMigrationRunner(db, migrations)
888
+ await runner.up()
889
+ ```
890
+
891
+ ### MySQL
892
+
893
+ ```typescript
894
+ import { Kysely, MysqlDialect } from 'kysely'
895
+ import { createPool } from 'mysql2'
896
+ import { createMigrationRunner, createMigration } from '@kysera/migrations'
897
+
898
+ const db = new Kysely({
899
+ dialect: new MysqlDialect({
900
+ pool: createPool({
901
+ host: 'localhost',
902
+ database: 'mydb',
903
+ user: 'user',
904
+ password: 'password'
905
+ })
906
+ })
907
+ })
908
+
909
+ const migrations = [
910
+ createMigration(
911
+ '001_create_users',
912
+ async (db) => {
913
+ await db.schema
914
+ .createTable('users')
915
+ .addColumn('id', 'integer', col =>
916
+ col.primaryKey().autoIncrement()
917
+ )
918
+ .addColumn('email', 'varchar(255)', col => col.notNull().unique())
919
+ .addColumn('created_at', 'datetime', col =>
920
+ col.notNull().defaultTo(db.fn('now'))
921
+ )
922
+ .execute()
923
+ },
924
+ async (db) => {
925
+ await db.schema.dropTable('users').execute()
926
+ }
927
+ )
928
+ ]
929
+
930
+ const runner = createMigrationRunner(db, migrations)
931
+ await runner.up()
932
+ ```
933
+
934
+ ### SQLite
935
+
936
+ ```typescript
937
+ import { Kysely, SqliteDialect } from 'kysely'
938
+ import Database from 'better-sqlite3'
939
+ import { createMigrationRunner, createMigration } from '@kysera/migrations'
940
+
941
+ const db = new Kysely({
942
+ dialect: new SqliteDialect({
943
+ database: new Database('mydb.sqlite')
944
+ })
945
+ })
946
+
947
+ const migrations = [
948
+ createMigration(
949
+ '001_create_users',
950
+ async (db) => {
951
+ await db.schema
952
+ .createTable('users')
953
+ .addColumn('id', 'integer', col =>
954
+ col.primaryKey().autoIncrement()
955
+ )
956
+ .addColumn('email', 'text', col => col.notNull().unique())
957
+ .addColumn('created_at', 'text', col =>
958
+ col.notNull().defaultTo(db.fn('datetime', ['now']))
959
+ )
960
+ .execute()
961
+ },
962
+ async (db) => {
963
+ await db.schema.dropTable('users').execute()
964
+ }
965
+ )
966
+ ]
967
+
968
+ const runner = createMigrationRunner(db, migrations)
969
+ await runner.up()
970
+ ```
971
+
972
+ ## 🐛 Troubleshooting
973
+
974
+ ### Migration Not Running
975
+
976
+ **Problem**: Migration appears in pending list but doesn't execute
977
+
978
+ **Solutions:**
979
+ ```typescript
980
+ // 1. Check migration is in the array
981
+ const migrations = [
982
+ migration_001,
983
+ migration_002,
984
+ migration_003 // Make sure it's included!
985
+ ]
986
+
987
+ // 2. Verify migration name is unique
988
+ const migrations = [
989
+ createMigration('001_users', ...),
990
+ createMigration('001_users', ...) // ❌ Duplicate name!
991
+ ]
992
+
993
+ // 3. Check for errors in up() function
994
+ createMigration('001_test', async (db) => {
995
+ try {
996
+ await db.schema.createTable('test')...execute()
997
+ } catch (error) {
998
+ console.error('Migration failed:', error)
999
+ throw error
1000
+ }
1001
+ })
1002
+ ```
1003
+
1004
+ ### Rollback Fails
1005
+
1006
+ **Problem**: down() throws error or doesn't work
1007
+
1008
+ **Solutions:**
1009
+ ```typescript
1010
+ // 1. Ensure down() method exists
1011
+ const migration = createMigration(
1012
+ '001_test',
1013
+ async (db) => { /* up */ },
1014
+ async (db) => { /* down - required for rollback! */ }
1015
+ )
1016
+
1017
+ // 2. Handle dependent objects (foreign keys, etc.)
1018
+ createMigration(
1019
+ '001_create_posts',
1020
+ async (db) => {
1021
+ await db.schema.createTable('posts')
1022
+ .addColumn('user_id', 'integer', col =>
1023
+ col.references('users.id')
1024
+ )
1025
+ .execute()
1026
+ },
1027
+ async (db) => {
1028
+ // Must drop in correct order
1029
+ await db.schema.dropTable('posts').execute() // ✅ Drop first
1030
+ // DON'T drop users table here if posts references it
1031
+ }
1032
+ )
1033
+
1034
+ // 3. Use CASCADE for PostgreSQL
1035
+ async (db) => {
1036
+ await db.schema.dropTable('users').cascade().execute()
1037
+ }
1038
+ ```
1039
+
1040
+ ### Migrations Table Not Found
1041
+
1042
+ **Problem**: `Error: Table 'migrations' does not exist`
1043
+
1044
+ **Solution:**
1045
+ ```typescript
1046
+ // The migrations table is created automatically,
1047
+ // but you can create it manually if needed:
1048
+ import { setupMigrations } from '@kysera/migrations'
1049
+
1050
+ await setupMigrations(db)
1051
+ await runner.up()
1052
+ ```
1053
+
1054
+ ### Duplicate Migration Names
1055
+
1056
+ **Problem**: Same migration runs twice or state is inconsistent
1057
+
1058
+ **Solution:**
1059
+ ```typescript
1060
+ // Ensure unique names with consistent prefixes
1061
+ const migrations = [
1062
+ createMigration('001_create_users', ...),
1063
+ createMigration('002_create_posts', ...),
1064
+ createMigration('003_add_indexes', ...)
1065
+ // Not: '001_create_users', '001_another_table', ...
1066
+ ]
1067
+
1068
+ // Check for duplicates programmatically
1069
+ const names = migrations.map(m => m.name)
1070
+ const duplicates = names.filter((n, i) => names.indexOf(n) !== i)
1071
+ if (duplicates.length > 0) {
1072
+ throw new Error(`Duplicate migrations: ${duplicates.join(', ')}`)
1073
+ }
1074
+ ```
1075
+
1076
+ ### Migration Stuck in Pending
1077
+
1078
+ **Problem**: Migration marked as executed but database changes not applied
1079
+
1080
+ **Solution:**
1081
+ ```typescript
1082
+ // Manually remove from migrations table and re-run
1083
+ await db
1084
+ .deleteFrom('migrations')
1085
+ .where('name', '=', 'problematic_migration_name')
1086
+ .execute()
1087
+
1088
+ await runner.up()
1089
+ ```
1090
+
1091
+ ## 📝 Examples
1092
+
1093
+ ### Complete Blog Application Migration
1094
+
1095
+ ```typescript
1096
+ import { createMigration } from '@kysera/migrations'
1097
+ import { sql } from 'kysely'
1098
+
1099
+ export const blogMigrations = [
1100
+ // 001: Create users table
1101
+ createMigration(
1102
+ '001_create_users',
1103
+ async (db) => {
1104
+ await db.schema
1105
+ .createTable('users')
1106
+ .addColumn('id', 'serial', col => col.primaryKey())
1107
+ .addColumn('email', 'varchar(255)', col => col.notNull().unique())
1108
+ .addColumn('username', 'varchar(50)', col => col.notNull().unique())
1109
+ .addColumn('password_hash', 'varchar(255)', col => col.notNull())
1110
+ .addColumn('created_at', 'timestamp', col =>
1111
+ col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
1112
+ )
1113
+ .execute()
1114
+
1115
+ await db.schema
1116
+ .createIndex('users_email_idx')
1117
+ .on('users')
1118
+ .column('email')
1119
+ .execute()
1120
+ },
1121
+ async (db) => {
1122
+ await db.schema.dropTable('users').cascade().execute()
1123
+ }
1124
+ ),
1125
+
1126
+ // 002: Create posts table
1127
+ createMigration(
1128
+ '002_create_posts',
1129
+ async (db) => {
1130
+ await db.schema
1131
+ .createTable('posts')
1132
+ .addColumn('id', 'serial', col => col.primaryKey())
1133
+ .addColumn('user_id', 'integer', col =>
1134
+ col.notNull().references('users.id').onDelete('cascade')
1135
+ )
1136
+ .addColumn('title', 'varchar(255)', col => col.notNull())
1137
+ .addColumn('slug', 'varchar(255)', col => col.notNull().unique())
1138
+ .addColumn('content', 'text', col => col.notNull())
1139
+ .addColumn('published', 'boolean', col => col.notNull().defaultTo(false))
1140
+ .addColumn('created_at', 'timestamp', col =>
1141
+ col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
1142
+ )
1143
+ .addColumn('updated_at', 'timestamp', col =>
1144
+ col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
1145
+ )
1146
+ .execute()
1147
+
1148
+ await db.schema
1149
+ .createIndex('posts_user_id_idx')
1150
+ .on('posts')
1151
+ .column('user_id')
1152
+ .execute()
1153
+
1154
+ await db.schema
1155
+ .createIndex('posts_slug_idx')
1156
+ .on('posts')
1157
+ .column('slug')
1158
+ .execute()
1159
+ },
1160
+ async (db) => {
1161
+ await db.schema.dropTable('posts').execute()
1162
+ }
1163
+ ),
1164
+
1165
+ // 003: Create comments table
1166
+ createMigration(
1167
+ '003_create_comments',
1168
+ async (db) => {
1169
+ await db.schema
1170
+ .createTable('comments')
1171
+ .addColumn('id', 'serial', col => col.primaryKey())
1172
+ .addColumn('post_id', 'integer', col =>
1173
+ col.notNull().references('posts.id').onDelete('cascade')
1174
+ )
1175
+ .addColumn('user_id', 'integer', col =>
1176
+ col.notNull().references('users.id').onDelete('cascade')
1177
+ )
1178
+ .addColumn('content', 'text', col => col.notNull())
1179
+ .addColumn('created_at', 'timestamp', col =>
1180
+ col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
1181
+ )
1182
+ .execute()
1183
+
1184
+ await db.schema
1185
+ .createIndex('comments_post_id_idx')
1186
+ .on('comments')
1187
+ .column('post_id')
1188
+ .execute()
1189
+ },
1190
+ async (db) => {
1191
+ await db.schema.dropTable('comments').execute()
1192
+ }
1193
+ ),
1194
+
1195
+ // 004: Add tags
1196
+ createMigration(
1197
+ '004_create_tags',
1198
+ async (db) => {
1199
+ await db.schema
1200
+ .createTable('tags')
1201
+ .addColumn('id', 'serial', col => col.primaryKey())
1202
+ .addColumn('name', 'varchar(50)', col => col.notNull().unique())
1203
+ .addColumn('slug', 'varchar(50)', col => col.notNull().unique())
1204
+ .execute()
1205
+
1206
+ await db.schema
1207
+ .createTable('post_tags')
1208
+ .addColumn('post_id', 'integer', col =>
1209
+ col.notNull().references('posts.id').onDelete('cascade')
1210
+ )
1211
+ .addColumn('tag_id', 'integer', col =>
1212
+ col.notNull().references('tags.id').onDelete('cascade')
1213
+ )
1214
+ .addPrimaryKeyConstraint('post_tags_pk', ['post_id', 'tag_id'])
1215
+ .execute()
1216
+
1217
+ await db.schema
1218
+ .createIndex('post_tags_tag_id_idx')
1219
+ .on('post_tags')
1220
+ .column('tag_id')
1221
+ .execute()
1222
+ },
1223
+ async (db) => {
1224
+ await db.schema.dropTable('post_tags').execute()
1225
+ await db.schema.dropTable('tags').execute()
1226
+ }
1227
+ ),
1228
+
1229
+ // 005: Add full-text search
1230
+ createMigration(
1231
+ '005_add_fulltext_search',
1232
+ async (db) => {
1233
+ // PostgreSQL specific
1234
+ await db.schema
1235
+ .alterTable('posts')
1236
+ .addColumn('search_vector', 'tsvector')
1237
+ .execute()
1238
+
1239
+ await sql`
1240
+ CREATE INDEX posts_search_vector_idx
1241
+ ON posts
1242
+ USING gin(search_vector)
1243
+ `.execute(db)
1244
+
1245
+ await sql`
1246
+ CREATE TRIGGER posts_search_vector_update
1247
+ BEFORE INSERT OR UPDATE ON posts
1248
+ FOR EACH ROW EXECUTE FUNCTION
1249
+ tsvector_update_trigger(
1250
+ search_vector, 'pg_catalog.english', title, content
1251
+ )
1252
+ `.execute(db)
1253
+ },
1254
+ async (db) => {
1255
+ await sql`DROP TRIGGER posts_search_vector_update ON posts`.execute(db)
1256
+ await db.schema
1257
+ .alterTable('posts')
1258
+ .dropColumn('search_vector')
1259
+ .execute()
1260
+ }
1261
+ )
1262
+ ]
1263
+
1264
+ // Usage
1265
+ const runner = createMigrationRunner(db, blogMigrations)
1266
+ await runner.up()
1267
+ ```
1268
+
1269
+ ### Migration Script
1270
+
1271
+ ```typescript
1272
+ // scripts/migrate.ts
1273
+ import { Kysely, PostgresDialect } from 'kysely'
1274
+ import { Pool } from 'pg'
1275
+ import { createMigrationRunner } from '@kysera/migrations'
1276
+ import { migrations } from '../migrations'
1277
+
1278
+ async function main() {
1279
+ const db = new Kysely({
1280
+ dialect: new PostgresDialect({
1281
+ pool: new Pool({
1282
+ connectionString: process.env.DATABASE_URL
1283
+ })
1284
+ })
1285
+ })
1286
+
1287
+ const command = process.argv[2]
1288
+ const runner = createMigrationRunner(db, migrations)
1289
+
1290
+ try {
1291
+ switch (command) {
1292
+ case 'up':
1293
+ console.log('Running migrations...')
1294
+ await runner.up()
1295
+ break
1296
+
1297
+ case 'down':
1298
+ const steps = parseInt(process.argv[3] || '1')
1299
+ console.log(`Rolling back ${steps} migration(s)...`)
1300
+ await runner.down(steps)
1301
+ break
1302
+
1303
+ case 'status':
1304
+ await runner.status()
1305
+ break
1306
+
1307
+ case 'reset':
1308
+ console.log('⚠️ WARNING: This will rollback ALL migrations!')
1309
+ await runner.reset()
1310
+ break
1311
+
1312
+ default:
1313
+ console.log('Usage: pnpm migrate [up|down|status|reset] [steps]')
1314
+ process.exit(1)
1315
+ }
1316
+ } catch (error) {
1317
+ console.error('Migration failed:', error)
1318
+ process.exit(1)
1319
+ } finally {
1320
+ await db.destroy()
1321
+ }
1322
+ }
1323
+
1324
+ main()
1325
+ ```
1326
+
1327
+ ```json
1328
+ // package.json
1329
+ {
1330
+ "scripts": {
1331
+ "migrate:up": "tsx scripts/migrate.ts up",
1332
+ "migrate:down": "tsx scripts/migrate.ts down",
1333
+ "migrate:status": "tsx scripts/migrate.ts status",
1334
+ "migrate:reset": "tsx scripts/migrate.ts reset"
1335
+ }
1336
+ }
1337
+ ```
1338
+
1339
+ ## 🔒 Type Safety
1340
+
1341
+ ### Fully Typed Migrations
1342
+
1343
+ ```typescript
1344
+ import type { Kysely } from 'kysely'
1345
+ import type { Database } from './database'
1346
+
1347
+ // Type-safe migration
1348
+ const migration = createMigration(
1349
+ '001_create_users',
1350
+ async (db: Kysely<Database>) => {
1351
+ await db.schema
1352
+ .createTable('users')
1353
+ .addColumn('id', 'serial', col => col.primaryKey())
1354
+ .addColumn('email', 'varchar(255)', col => col.notNull())
1355
+ .execute()
1356
+ },
1357
+ async (db: Kysely<Database>) => {
1358
+ await db.schema.dropTable('users').execute()
1359
+ }
1360
+ )
1361
+
1362
+ // Type-safe data migration
1363
+ createMigration(
1364
+ '002_migrate_data',
1365
+ async (db: Kysely<Database>) => {
1366
+ // TypeScript knows about 'users' table
1367
+ const users = await db
1368
+ .selectFrom('users')
1369
+ .selectAll()
1370
+ .execute()
1371
+
1372
+ // Type-safe operations
1373
+ for (const user of users) {
1374
+ await db
1375
+ .updateTable('users')
1376
+ .set({ email: user.email.toLowerCase() })
1377
+ .where('id', '=', user.id)
1378
+ .execute()
1379
+ }
1380
+ }
1381
+ )
1382
+ ```
1383
+
1384
+ ## 📄 License
1385
+
1386
+ MIT © [Kysera Team](https://github.com/omnitron-dev)
1387
+
1388
+ ## 🤝 Contributing
1389
+
1390
+ Contributions are welcome! Please read our [Contributing Guide](../../CONTRIBUTING.md) for details.
1391
+
1392
+ ## 📚 Related Packages
1393
+
1394
+ - [`@kysera/core`](../core) - Core utilities and error handling
1395
+ - [`@kysera/repository`](../repository) - Repository pattern implementation
1396
+ - [`@kysera/audit`](../audit) - Audit logging plugin
1397
+ - [`@kysera/soft-delete`](../soft-delete) - Soft delete plugin
1398
+ - [`@kysera/timestamps`](../timestamps) - Automatic timestamp management
1399
+
1400
+ ## 🔗 Links
1401
+
1402
+ - [Documentation](https://kysera.dev/docs/migrations)
1403
+ - [GitHub Repository](https://github.com/omnitron-dev/kysera)
1404
+ - [Issue Tracker](https://github.com/omnitron-dev/kysera/issues)
1405
+ - [Kysely Documentation](https://kysely.dev)
1406
+
1407
+ ---
1408
+
1409
+ **Built with ❤️ using TypeScript and Kysely**