@chidchanun/bcp 0.1.14 → 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,105 @@
1
+ # Database Migrations
2
+
3
+ BCP Framework `0.1.15` adds MySQL migration commands to the `bcp` CLI.
4
+
5
+ ## Requirements
6
+
7
+ Database migrations currently use the MySQL adapter from `bcp/database`.
8
+
9
+ Configure the same environment variables used by your application:
10
+
11
+ ```env
12
+ DB_HOST=localhost
13
+ DB_PORT=3306
14
+ DB_USER=root
15
+ DB_PASSWORD=
16
+ DB_NAME=bcp_app
17
+ ```
18
+
19
+ A project using migrations must have `mysql2` installed. Applications created with the MySQL preset already include it.
20
+
21
+ ## Create a migration
22
+
23
+ ```bash
24
+ bcp db create create_users
25
+ ```
26
+
27
+ BCP creates an ordered TypeScript file in `migrations/`:
28
+
29
+ ```text
30
+ migrations/
31
+ 20260827040506_create_users.ts
32
+ ```
33
+
34
+ A generated migration exports `up()` and `down()`:
35
+
36
+ ```ts
37
+ import type {
38
+ TransactionDatabase,
39
+ } from "bcp/database";
40
+
41
+ export async function up(
42
+ db: TransactionDatabase
43
+ ): Promise<void> {
44
+ await db.execute(`
45
+ CREATE TABLE users (
46
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
47
+ email VARCHAR(255) NOT NULL UNIQUE
48
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
49
+ `);
50
+ }
51
+
52
+ export async function down(
53
+ db: TransactionDatabase
54
+ ): Promise<void> {
55
+ await db.execute(
56
+ "DROP TABLE users"
57
+ );
58
+ }
59
+ ```
60
+
61
+ Migration filenames use a UTC timestamp prefix so migrations have a stable execution order.
62
+
63
+ ## Run pending migrations
64
+
65
+ ```bash
66
+ bcp db migrate
67
+ ```
68
+
69
+ BCP creates the internal `_bcp_migrations` table when needed, detects files that have not been applied, and runs all pending migrations in filename order.
70
+
71
+ All migrations applied by one `bcp db migrate` command share the same batch number. Each individual migration runs inside its own database transaction. The migration record is inserted in the same transaction as `up()`.
72
+
73
+ ## Check status
74
+
75
+ ```bash
76
+ bcp db status
77
+ ```
78
+
79
+ The command reports applied and pending migration files together with the batch number for applied migrations.
80
+
81
+ ## Roll back
82
+
83
+ ```bash
84
+ bcp db rollback
85
+ ```
86
+
87
+ Rollback reverses only the latest migration batch. Migrations in that batch run from newest to oldest, and each `down()` runs in a transaction together with removal of its migration record.
88
+
89
+ BCP refuses to roll back an applied migration when its migration file is missing.
90
+
91
+ ## Project root
92
+
93
+ All database commands support the normal BCP project-root option:
94
+
95
+ ```bash
96
+ bcp db status --root ./apps/admin
97
+ ```
98
+
99
+ ## Environment files
100
+
101
+ Database commands load the BCP development environment files before connecting, so the same local database settings used by `bcp dev` can be reused by migration commands.
102
+
103
+ ## Current scope
104
+
105
+ `0.1.15` migration execution supports MySQL. PostgreSQL, SQLite and MongoDB presets remain available to `create-bcp-app`, but framework-managed migrations for those adapters are planned for later releases.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,9 +7,17 @@ export type CliCommand =
7
7
  | "start"
8
8
  | "routes"
9
9
  | "update"
10
+ | "db"
10
11
  | "help"
11
12
  | "version";
12
13
 
14
+ export type DatabaseCliAction =
15
+ | "migrate"
16
+ | "status"
17
+ | "rollback"
18
+ | "create"
19
+ | "help";
20
+
13
21
  export interface CliOptions {
14
22
  command: CliCommand;
15
23
 
@@ -24,6 +32,10 @@ export interface CliOptions {
24
32
  updateCheck?: boolean;
25
33
 
26
34
  updateDryRun?: boolean;
35
+
36
+ dbAction?: DatabaseCliAction;
37
+
38
+ dbMigrationName?: string;
27
39
  }
28
40
 
29
41
  export function parseCliArgs(
@@ -49,6 +61,12 @@ export function parseCliArgs(
49
61
  let updateDryRun =
50
62
  false;
51
63
 
64
+ let dbAction:
65
+ DatabaseCliAction | undefined;
66
+
67
+ let dbMigrationName:
68
+ string | undefined;
69
+
52
70
  let commandSet = false;
53
71
 
54
72
  for (
@@ -62,7 +80,14 @@ export function parseCliArgs(
62
80
  argument === "-h" ||
63
81
  argument === "--help"
64
82
  ) {
65
- command = "help";
83
+ if (
84
+ commandSet &&
85
+ command === "db"
86
+ ) {
87
+ dbAction = "help";
88
+ } else {
89
+ command = "help";
90
+ }
66
91
  continue;
67
92
  }
68
93
 
@@ -221,6 +246,44 @@ export function parseCliArgs(
221
246
  continue;
222
247
  }
223
248
 
249
+ if (
250
+ commandSet &&
251
+ command === "db"
252
+ ) {
253
+ if (
254
+ dbAction === undefined
255
+ ) {
256
+ if (
257
+ argument === "migrate" ||
258
+ argument === "status" ||
259
+ argument === "rollback" ||
260
+ argument === "create" ||
261
+ argument === "help"
262
+ ) {
263
+ dbAction =
264
+ argument;
265
+ continue;
266
+ }
267
+
268
+ throw new Error(
269
+ `Unknown database command: ${argument}`
270
+ );
271
+ }
272
+
273
+ if (
274
+ dbAction === "create" &&
275
+ dbMigrationName === undefined
276
+ ) {
277
+ dbMigrationName =
278
+ argument;
279
+ continue;
280
+ }
281
+
282
+ throw new Error(
283
+ `Unexpected argument: ${argument}`
284
+ );
285
+ }
286
+
224
287
  if (commandSet) {
225
288
  throw new Error(
226
289
  `Unexpected argument: ${argument}`
@@ -233,6 +296,7 @@ export function parseCliArgs(
233
296
  argument === "start" ||
234
297
  argument === "routes" ||
235
298
  argument === "update" ||
299
+ argument === "db" ||
236
300
  argument === "help" ||
237
301
  argument === "version"
238
302
  ) {
@@ -266,6 +330,12 @@ export function parseCliArgs(
266
330
  updateTarget,
267
331
  updateCheck,
268
332
  updateDryRun,
333
+ ...(command === "db"
334
+ ? {
335
+ dbAction,
336
+ dbMigrationName,
337
+ }
338
+ : {}),
269
339
  };
270
340
  }
271
341
 
@@ -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
+ }
@@ -66,6 +66,11 @@ switch (cliOptions.command) {
66
66
  break;
67
67
  }
68
68
 
69
+ case "db": {
70
+ await runDatabaseCommand();
71
+ break;
72
+ }
73
+
69
74
  case "help": {
70
75
  printHelp();
71
76
  break;
@@ -381,6 +386,86 @@ async function runUpdate() {
381
386
  });
382
387
  }
383
388
 
389
+ async function runDatabaseCommand() {
390
+ const action =
391
+ cliOptions.dbAction;
392
+
393
+ if (
394
+ action === undefined ||
395
+ action === "help"
396
+ ) {
397
+ printDatabaseHelp();
398
+ return;
399
+ }
400
+
401
+ const rootDirectory =
402
+ resolveProjectRoot(
403
+ cliOptions.rootDirectory
404
+ );
405
+
406
+ process.env.NODE_ENV =
407
+ process.env.NODE_ENV ??
408
+ "development";
409
+
410
+ const {
411
+ loadEnvironment,
412
+ } =
413
+ await import(
414
+ "../../env/src/index.js"
415
+ );
416
+
417
+ const environment =
418
+ loadEnvironment(
419
+ rootDirectory,
420
+ "development"
421
+ );
422
+
423
+ if (
424
+ environment.files.length > 0
425
+ ) {
426
+ console.log(
427
+ `[BCP Env] development: ${environment.files.join(", ")}`
428
+ );
429
+ }
430
+
431
+ const {
432
+ runDatabaseMigrationCommand,
433
+ } =
434
+ await import(
435
+ "./database-migrations.js"
436
+ );
437
+
438
+ if (
439
+ action === "create"
440
+ ) {
441
+ await runDatabaseMigrationCommand({
442
+ rootDirectory,
443
+ action,
444
+ name:
445
+ cliOptions.dbMigrationName,
446
+ });
447
+ return;
448
+ }
449
+
450
+ const {
451
+ db,
452
+ } =
453
+ await import(
454
+ "../../client/src/database.js"
455
+ );
456
+
457
+ try {
458
+ await runDatabaseMigrationCommand({
459
+ rootDirectory,
460
+ action,
461
+ database:
462
+ db,
463
+ });
464
+ } finally {
465
+ await db.close();
466
+ }
467
+ }
468
+
384
469
  function installShutdownHandlers(
385
470
  server: {
386
471
  stop(): Promise<void>;
@@ -521,6 +606,28 @@ function formatBytes(
521
606
  ).toFixed(2)} MiB`;
522
607
  }
523
608
 
609
+ function printDatabaseHelp() {
610
+ console.log(`
611
+ BCP Database Migrations
612
+
613
+ Usage:
614
+ bcp db <command> [arguments]
615
+
616
+ Commands:
617
+ create <name> Create a timestamped migration in migrations/
618
+ migrate Run all pending migrations
619
+ status Show applied and pending migrations
620
+ rollback Roll back the latest migration batch
621
+ help Show database command help
622
+
623
+ Examples:
624
+ bcp db create create_users
625
+ bcp db migrate
626
+ bcp db status
627
+ bcp db rollback
628
+ `);
629
+ }
630
+
524
631
  function printHelp() {
525
632
  console.log(`
526
633
  BCP Framework v${FRAMEWORK_VERSION}
@@ -534,6 +641,7 @@ Commands:
534
641
  start Start the standalone production build
535
642
  routes Print discovered page and API routes
536
643
  update [target] Update BCP Framework (default target: latest)
644
+ db <command> Manage database migrations
537
645
  help Show this help message
538
646
  version Show framework version
539
647
 
@@ -558,6 +666,9 @@ Examples:
558
666
  bcp routes
559
667
  bcp update
560
668
  bcp update --check
561
- bcp update 0.1.10
669
+ bcp db create create_users
670
+ bcp db migrate
671
+ bcp db status
672
+ bcp db rollback
562
673
  `);
563
674
  }