@chidchanun/bcp 0.1.13 → 0.1.15

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.
@@ -0,0 +1,708 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ pathToFileURL,
5
+ } from "node:url";
6
+
7
+ import type {
8
+ TransactionDatabase,
9
+ } from "../../client/src/database.js";
10
+
11
+ export type DatabaseMigrationAction =
12
+ | "migrate"
13
+ | "status"
14
+ | "rollback"
15
+ | "create";
16
+
17
+ export interface MigrationDatabase {
18
+ query<T = unknown>(
19
+ sql: string,
20
+ parameters?: readonly unknown[] | Record<string, unknown>
21
+ ): Promise<T>;
22
+ execute<T = unknown>(
23
+ sql: string,
24
+ parameters?: readonly unknown[] | Record<string, unknown>
25
+ ): Promise<T>;
26
+ transaction<T>(
27
+ callback: (
28
+ database: TransactionDatabase
29
+ ) => Promise<T>
30
+ ): Promise<T>;
31
+ }
32
+
33
+ export interface DatabaseMigrationCommandOptions {
34
+ rootDirectory: string;
35
+ action: DatabaseMigrationAction;
36
+ name?: string;
37
+ database?: MigrationDatabase;
38
+ now?: Date;
39
+ }
40
+
41
+ export interface MigrationStatus {
42
+ name: string;
43
+ status: "applied" | "pending";
44
+ batch: number | null;
45
+ appliedAt: string | Date | null;
46
+ }
47
+
48
+ interface MigrationRecord {
49
+ name: string;
50
+ batch: number;
51
+ applied_at: string | Date;
52
+ }
53
+
54
+ interface LoadedMigration {
55
+ name: string;
56
+ filePath: string;
57
+ }
58
+
59
+ interface MigrationModule {
60
+ up?: (
61
+ database: TransactionDatabase
62
+ ) => Promise<void> | void;
63
+ down?: (
64
+ database: TransactionDatabase
65
+ ) => Promise<void> | void;
66
+ }
67
+
68
+ const MIGRATION_DIRECTORY =
69
+ "migrations";
70
+ const MIGRATION_TABLE =
71
+ "_bcp_migrations";
72
+ const MIGRATION_FILE_PATTERN =
73
+ /^\d{14}_[a-z0-9][a-z0-9_-]*\.(?:ts|mts|js|mjs)$/;
74
+
75
+ export async function runDatabaseMigrationCommand(
76
+ options: DatabaseMigrationCommandOptions
77
+ ): Promise<void> {
78
+ const rootDirectory =
79
+ path.resolve(
80
+ options.rootDirectory
81
+ );
82
+
83
+ if (
84
+ options.action === "create"
85
+ ) {
86
+ const file =
87
+ createMigrationFile(
88
+ rootDirectory,
89
+ options.name,
90
+ options.now
91
+ );
92
+
93
+ console.log(
94
+ `[BCP DB] Created migration: ${path.relative(rootDirectory, file)}`
95
+ );
96
+ return;
97
+ }
98
+
99
+ const database =
100
+ options.database;
101
+
102
+ if (!database) {
103
+ throw new Error(
104
+ "BCP DB: a database connection is required for this command."
105
+ );
106
+ }
107
+
108
+ await ensureMigrationTable(
109
+ database
110
+ );
111
+
112
+ if (
113
+ options.action === "status"
114
+ ) {
115
+ const statuses =
116
+ await getMigrationStatus(
117
+ rootDirectory,
118
+ database
119
+ );
120
+
121
+ printMigrationStatus(
122
+ statuses
123
+ );
124
+ return;
125
+ }
126
+
127
+ if (
128
+ options.action === "migrate"
129
+ ) {
130
+ await migratePending(
131
+ rootDirectory,
132
+ database
133
+ );
134
+ return;
135
+ }
136
+
137
+ await rollbackLatestBatch(
138
+ rootDirectory,
139
+ database
140
+ );
141
+ }
142
+
143
+ export function createMigrationFile(
144
+ rootDirectory: string,
145
+ name: string | undefined,
146
+ now = new Date()
147
+ ): string {
148
+ const slug =
149
+ normalizeMigrationName(
150
+ name
151
+ );
152
+ const directory =
153
+ path.join(
154
+ rootDirectory,
155
+ MIGRATION_DIRECTORY
156
+ );
157
+
158
+ fs.mkdirSync(
159
+ directory,
160
+ {
161
+ recursive: true,
162
+ }
163
+ );
164
+
165
+ const timestamp =
166
+ formatMigrationTimestamp(
167
+ now
168
+ );
169
+ let suffix = 1;
170
+ let fileName =
171
+ `${timestamp}_${slug}.ts`;
172
+ let filePath =
173
+ path.join(
174
+ directory,
175
+ fileName
176
+ );
177
+
178
+ while (
179
+ fs.existsSync(
180
+ filePath
181
+ )
182
+ ) {
183
+ suffix++;
184
+ fileName =
185
+ `${timestamp}_${slug}_${suffix}.ts`;
186
+ filePath =
187
+ path.join(
188
+ directory,
189
+ fileName
190
+ );
191
+ }
192
+
193
+ const source =
194
+ `import type {\n` +
195
+ ` TransactionDatabase,\n` +
196
+ `} from "bcp/database";\n\n` +
197
+ `export async function up(\n` +
198
+ ` db: TransactionDatabase\n` +
199
+ `): Promise<void> {\n` +
200
+ ` // Write the forward migration here.\n` +
201
+ ` void db;\n` +
202
+ `}\n\n` +
203
+ `export async function down(\n` +
204
+ ` db: TransactionDatabase\n` +
205
+ `): Promise<void> {\n` +
206
+ ` // Write the rollback migration here.\n` +
207
+ ` void db;\n` +
208
+ `}\n`;
209
+
210
+ fs.writeFileSync(
211
+ filePath,
212
+ source,
213
+ {
214
+ encoding: "utf8",
215
+ flag: "wx",
216
+ }
217
+ );
218
+
219
+ return filePath;
220
+ }
221
+
222
+ export function listMigrationFiles(
223
+ rootDirectory: string
224
+ ): LoadedMigration[] {
225
+ const directory =
226
+ path.join(
227
+ rootDirectory,
228
+ MIGRATION_DIRECTORY
229
+ );
230
+
231
+ if (
232
+ !fs.existsSync(
233
+ directory
234
+ )
235
+ ) {
236
+ return [];
237
+ }
238
+
239
+ return fs.readdirSync(
240
+ directory,
241
+ {
242
+ withFileTypes: true,
243
+ }
244
+ )
245
+ .filter(
246
+ (entry) =>
247
+ entry.isFile() &&
248
+ /\.(?:ts|mts|js|mjs)$/.test(
249
+ entry.name
250
+ )
251
+ )
252
+ .map(
253
+ (entry) => {
254
+ if (
255
+ !MIGRATION_FILE_PATTERN.test(
256
+ entry.name
257
+ )
258
+ ) {
259
+ throw new Error(
260
+ `BCP DB: invalid migration filename "${entry.name}". Use \`bcp db create <name>\` to create ordered migrations.`
261
+ );
262
+ }
263
+
264
+ return {
265
+ name:
266
+ entry.name,
267
+ filePath:
268
+ path.join(
269
+ directory,
270
+ entry.name
271
+ ),
272
+ };
273
+ }
274
+ )
275
+ .sort(
276
+ (left, right) =>
277
+ left.name.localeCompare(
278
+ right.name
279
+ )
280
+ );
281
+ }
282
+
283
+ export async function getMigrationStatus(
284
+ rootDirectory: string,
285
+ database: MigrationDatabase
286
+ ): Promise<MigrationStatus[]> {
287
+ const files =
288
+ listMigrationFiles(
289
+ rootDirectory
290
+ );
291
+ const applied =
292
+ await readAppliedMigrations(
293
+ database
294
+ );
295
+ const appliedByName =
296
+ new Map(
297
+ applied.map(
298
+ (record) => [
299
+ record.name,
300
+ record,
301
+ ]
302
+ )
303
+ );
304
+ const statuses:
305
+ MigrationStatus[] =
306
+ files.map(
307
+ (migration) => {
308
+ const record =
309
+ appliedByName.get(
310
+ migration.name
311
+ );
312
+
313
+ return record
314
+ ? {
315
+ name:
316
+ migration.name,
317
+ status:
318
+ "applied",
319
+ batch:
320
+ record.batch,
321
+ appliedAt:
322
+ record.applied_at,
323
+ }
324
+ : {
325
+ name:
326
+ migration.name,
327
+ status:
328
+ "pending",
329
+ batch:
330
+ null,
331
+ appliedAt:
332
+ null,
333
+ };
334
+ }
335
+ );
336
+
337
+ for (
338
+ const record
339
+ of applied
340
+ ) {
341
+ if (
342
+ !files.some(
343
+ (file) =>
344
+ file.name ===
345
+ record.name
346
+ )
347
+ ) {
348
+ statuses.push({
349
+ name:
350
+ record.name,
351
+ status:
352
+ "applied",
353
+ batch:
354
+ record.batch,
355
+ appliedAt:
356
+ record.applied_at,
357
+ });
358
+ }
359
+ }
360
+
361
+ return statuses;
362
+ }
363
+
364
+ async function migratePending(
365
+ rootDirectory: string,
366
+ database: MigrationDatabase
367
+ ): Promise<void> {
368
+ const files =
369
+ listMigrationFiles(
370
+ rootDirectory
371
+ );
372
+ const applied =
373
+ await readAppliedMigrations(
374
+ database
375
+ );
376
+ const appliedNames =
377
+ new Set(
378
+ applied.map(
379
+ (record) =>
380
+ record.name
381
+ )
382
+ );
383
+ const pending =
384
+ files.filter(
385
+ (migration) =>
386
+ !appliedNames.has(
387
+ migration.name
388
+ )
389
+ );
390
+
391
+ if (
392
+ pending.length === 0
393
+ ) {
394
+ console.log(
395
+ "[BCP DB] No pending migrations."
396
+ );
397
+ return;
398
+ }
399
+
400
+ const batch =
401
+ applied.reduce(
402
+ (
403
+ maximum,
404
+ record
405
+ ) =>
406
+ Math.max(
407
+ maximum,
408
+ record.batch
409
+ ),
410
+ 0
411
+ ) + 1;
412
+
413
+ for (
414
+ const migration
415
+ of pending
416
+ ) {
417
+ const module =
418
+ await loadMigrationModule(
419
+ migration
420
+ );
421
+
422
+ await database.transaction(
423
+ async (transaction) => {
424
+ await module.up?.(
425
+ transaction
426
+ );
427
+ await transaction.execute(
428
+ `INSERT INTO ${MIGRATION_TABLE} (name, batch) VALUES (?, ?)`,
429
+ [
430
+ migration.name,
431
+ batch,
432
+ ]
433
+ );
434
+ }
435
+ );
436
+
437
+ console.log(
438
+ `[BCP DB] Migrated: ${migration.name} (batch ${batch})`
439
+ );
440
+ }
441
+ }
442
+
443
+ async function rollbackLatestBatch(
444
+ rootDirectory: string,
445
+ database: MigrationDatabase
446
+ ): Promise<void> {
447
+ const applied =
448
+ await readAppliedMigrations(
449
+ database
450
+ );
451
+
452
+ if (
453
+ applied.length === 0
454
+ ) {
455
+ console.log(
456
+ "[BCP DB] Nothing to rollback."
457
+ );
458
+ return;
459
+ }
460
+
461
+ const latestBatch =
462
+ applied.reduce(
463
+ (
464
+ maximum,
465
+ record
466
+ ) =>
467
+ Math.max(
468
+ maximum,
469
+ record.batch
470
+ ),
471
+ 0
472
+ );
473
+ const files =
474
+ new Map(
475
+ listMigrationFiles(
476
+ rootDirectory
477
+ ).map(
478
+ (migration) => [
479
+ migration.name,
480
+ migration,
481
+ ]
482
+ )
483
+ );
484
+ const rollbackRecords =
485
+ applied
486
+ .filter(
487
+ (record) =>
488
+ record.batch ===
489
+ latestBatch
490
+ )
491
+ .reverse();
492
+
493
+ for (
494
+ const record
495
+ of rollbackRecords
496
+ ) {
497
+ if (
498
+ !files.has(
499
+ record.name
500
+ )
501
+ ) {
502
+ throw new Error(
503
+ `BCP DB: cannot rollback ${record.name}; the migration file is missing.`
504
+ );
505
+ }
506
+ }
507
+
508
+ for (
509
+ const record
510
+ of rollbackRecords
511
+ ) {
512
+ const migration =
513
+ files.get(
514
+ record.name
515
+ )!;
516
+ const module =
517
+ await loadMigrationModule(
518
+ migration
519
+ );
520
+
521
+ await database.transaction(
522
+ async (transaction) => {
523
+ await module.down?.(
524
+ transaction
525
+ );
526
+ await transaction.execute(
527
+ `DELETE FROM ${MIGRATION_TABLE} WHERE name = ?`,
528
+ [
529
+ record.name,
530
+ ]
531
+ );
532
+ }
533
+ );
534
+
535
+ console.log(
536
+ `[BCP DB] Rolled back: ${record.name} (batch ${latestBatch})`
537
+ );
538
+ }
539
+ }
540
+
541
+ async function ensureMigrationTable(
542
+ database: MigrationDatabase
543
+ ): Promise<void> {
544
+ await database.execute(
545
+ `CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (` +
546
+ `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, ` +
547
+ `name VARCHAR(255) NOT NULL UNIQUE, ` +
548
+ `batch INT UNSIGNED NOT NULL, ` +
549
+ `applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` +
550
+ `) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
551
+ );
552
+ }
553
+
554
+ async function readAppliedMigrations(
555
+ database: MigrationDatabase
556
+ ): Promise<MigrationRecord[]> {
557
+ return database.query<
558
+ MigrationRecord[]
559
+ >(
560
+ `SELECT name, batch, applied_at FROM ${MIGRATION_TABLE} ORDER BY id ASC`
561
+ );
562
+ }
563
+
564
+ async function loadMigrationModule(
565
+ migration: LoadedMigration
566
+ ): Promise<Required<MigrationModule>> {
567
+ const url =
568
+ pathToFileURL(
569
+ migration.filePath
570
+ );
571
+
572
+ url.searchParams.set(
573
+ "bcp-migration",
574
+ `${Date.now()}-${Math.random()}`
575
+ );
576
+
577
+ const module =
578
+ await import(
579
+ url.href
580
+ ) as MigrationModule;
581
+
582
+ if (
583
+ typeof module.up !==
584
+ "function" ||
585
+ typeof module.down !==
586
+ "function"
587
+ ) {
588
+ throw new Error(
589
+ `BCP DB: migration ${migration.name} must export async up(db) and down(db) functions.`
590
+ );
591
+ }
592
+
593
+ return {
594
+ up:
595
+ module.up,
596
+ down:
597
+ module.down,
598
+ };
599
+ }
600
+
601
+ function normalizeMigrationName(
602
+ value: string | undefined
603
+ ): string {
604
+ const normalized =
605
+ String(
606
+ value ?? ""
607
+ )
608
+ .trim()
609
+ .toLowerCase()
610
+ .replace(
611
+ /[^a-z0-9]+/g,
612
+ "_"
613
+ )
614
+ .replace(
615
+ /^_+|_+$/g,
616
+ ""
617
+ );
618
+
619
+ if (!normalized) {
620
+ throw new Error(
621
+ "BCP DB: migration name is required. Example: bcp db create create_users"
622
+ );
623
+ }
624
+
625
+ return normalized;
626
+ }
627
+
628
+ function formatMigrationTimestamp(
629
+ value: Date
630
+ ): string {
631
+ if (
632
+ Number.isNaN(
633
+ value.getTime()
634
+ )
635
+ ) {
636
+ throw new Error(
637
+ "BCP DB: invalid migration timestamp."
638
+ );
639
+ }
640
+
641
+ return [
642
+ value.getUTCFullYear(),
643
+ value.getUTCMonth() + 1,
644
+ value.getUTCDate(),
645
+ value.getUTCHours(),
646
+ value.getUTCMinutes(),
647
+ value.getUTCSeconds(),
648
+ ]
649
+ .map(
650
+ (
651
+ part,
652
+ index
653
+ ) =>
654
+ index === 0
655
+ ? String(part)
656
+ .padStart(
657
+ 4,
658
+ "0"
659
+ )
660
+ : String(part)
661
+ .padStart(
662
+ 2,
663
+ "0"
664
+ )
665
+ )
666
+ .join("");
667
+ }
668
+
669
+ function printMigrationStatus(
670
+ statuses: MigrationStatus[]
671
+ ): void {
672
+ console.log("");
673
+ console.log(
674
+ "BCP Database Migrations"
675
+ );
676
+ console.log("");
677
+
678
+ if (
679
+ statuses.length === 0
680
+ ) {
681
+ console.log(
682
+ " (no migrations)"
683
+ );
684
+ console.log("");
685
+ return;
686
+ }
687
+
688
+ for (
689
+ const migration
690
+ of statuses
691
+ ) {
692
+ const marker =
693
+ migration.status ===
694
+ "applied"
695
+ ? "✓"
696
+ : "○";
697
+ const batch =
698
+ migration.batch === null
699
+ ? "pending"
700
+ : `batch ${migration.batch}`;
701
+
702
+ console.log(
703
+ ` ${marker} ${migration.name} — ${batch}`
704
+ );
705
+ }
706
+
707
+ console.log("");
708
+ }