@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.
@@ -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
  }
@@ -0,0 +1,492 @@
1
+ export type DatabaseDriver = "mysql";
2
+
3
+ export interface DatabaseOptions {
4
+ driver?: DatabaseDriver;
5
+ host?: string;
6
+ port?: number;
7
+ user?: string;
8
+ password?: string;
9
+ database?: string;
10
+ connectionLimit?: number;
11
+ waitForConnections?: boolean;
12
+ queueLimit?: number;
13
+ charset?: string;
14
+ }
15
+
16
+ export type DatabaseParameters =
17
+ | readonly unknown[]
18
+ | Record<string, unknown>;
19
+
20
+ export interface TransactionDatabase {
21
+ query<T = unknown>(
22
+ sql: string,
23
+ parameters?: DatabaseParameters
24
+ ): Promise<T>;
25
+ execute<T = unknown>(
26
+ sql: string,
27
+ parameters?: DatabaseParameters
28
+ ): Promise<T>;
29
+ }
30
+
31
+ interface MysqlExecutor {
32
+ query(
33
+ sql: string,
34
+ parameters?: DatabaseParameters
35
+ ): Promise<[unknown, unknown]>;
36
+ execute(
37
+ sql: string,
38
+ parameters?: DatabaseParameters
39
+ ): Promise<[unknown, unknown]>;
40
+ }
41
+
42
+ interface MysqlConnection extends MysqlExecutor {
43
+ beginTransaction(): Promise<void>;
44
+ commit(): Promise<void>;
45
+ rollback(): Promise<void>;
46
+ release(): void;
47
+ }
48
+
49
+ interface MysqlPool extends MysqlExecutor {
50
+ getConnection(): Promise<MysqlConnection>;
51
+ end(): Promise<void>;
52
+ }
53
+
54
+ interface MysqlModule {
55
+ createPool(
56
+ options: Record<string, unknown>
57
+ ): MysqlPool;
58
+ }
59
+
60
+ const MYSQL_MODULE_SPECIFIER =
61
+ "mysql2/promise";
62
+
63
+ export class BcpDatabase {
64
+ private readonly options:
65
+ DatabaseOptions;
66
+
67
+ private poolPromise:
68
+ Promise<MysqlPool> | null =
69
+ null;
70
+
71
+ constructor(
72
+ options: DatabaseOptions = {}
73
+ ) {
74
+ this.options = {
75
+ ...options,
76
+ };
77
+ }
78
+
79
+ async query<T = unknown>(
80
+ sql: string,
81
+ parameters?: DatabaseParameters
82
+ ): Promise<T> {
83
+ assertSql(sql);
84
+
85
+ const pool =
86
+ await this.getPool();
87
+ const [rows] =
88
+ await pool.query(
89
+ sql,
90
+ parameters
91
+ );
92
+
93
+ return rows as T;
94
+ }
95
+
96
+ async execute<T = unknown>(
97
+ sql: string,
98
+ parameters?: DatabaseParameters
99
+ ): Promise<T> {
100
+ assertSql(sql);
101
+
102
+ const pool =
103
+ await this.getPool();
104
+ const [result] =
105
+ await pool.execute(
106
+ sql,
107
+ parameters
108
+ );
109
+
110
+ return result as T;
111
+ }
112
+
113
+ async transaction<T>(
114
+ callback: (
115
+ database: TransactionDatabase
116
+ ) => Promise<T>
117
+ ): Promise<T> {
118
+ if (
119
+ typeof callback !==
120
+ "function"
121
+ ) {
122
+ throw new TypeError(
123
+ "BCP Database: transaction callback must be a function."
124
+ );
125
+ }
126
+
127
+ const pool =
128
+ await this.getPool();
129
+ const connection =
130
+ await pool.getConnection();
131
+
132
+ await connection.beginTransaction();
133
+
134
+ const transactionDatabase:
135
+ TransactionDatabase = {
136
+ query: async <R = unknown>(
137
+ sql: string,
138
+ parameters?: DatabaseParameters
139
+ ): Promise<R> => {
140
+ assertSql(sql);
141
+
142
+ const [rows] =
143
+ await connection.query(
144
+ sql,
145
+ parameters
146
+ );
147
+
148
+ return rows as R;
149
+ },
150
+ execute: async <R = unknown>(
151
+ sql: string,
152
+ parameters?: DatabaseParameters
153
+ ): Promise<R> => {
154
+ assertSql(sql);
155
+
156
+ const [result] =
157
+ await connection.execute(
158
+ sql,
159
+ parameters
160
+ );
161
+
162
+ return result as R;
163
+ },
164
+ };
165
+
166
+ try {
167
+ const result =
168
+ await callback(
169
+ transactionDatabase
170
+ );
171
+
172
+ await connection.commit();
173
+
174
+ return result;
175
+ } catch (error) {
176
+ try {
177
+ await connection.rollback();
178
+ } catch {
179
+ // Preserve the original transaction error.
180
+ }
181
+
182
+ throw error;
183
+ } finally {
184
+ connection.release();
185
+ }
186
+ }
187
+
188
+ async close(): Promise<void> {
189
+ const pendingPool =
190
+ this.poolPromise;
191
+
192
+ this.poolPromise =
193
+ null;
194
+
195
+ if (!pendingPool) {
196
+ return;
197
+ }
198
+
199
+ const pool =
200
+ await pendingPool;
201
+
202
+ await pool.end();
203
+ }
204
+
205
+ private getPool(): Promise<MysqlPool> {
206
+ if (!this.poolPromise) {
207
+ this.poolPromise =
208
+ createMysqlPool(
209
+ resolveDatabaseOptions(
210
+ this.options
211
+ )
212
+ );
213
+ }
214
+
215
+ return this.poolPromise;
216
+ }
217
+ }
218
+
219
+ export function createDatabase(
220
+ options: DatabaseOptions = {}
221
+ ): BcpDatabase {
222
+ return new BcpDatabase(
223
+ options
224
+ );
225
+ }
226
+
227
+ export function resolveDatabaseOptions(
228
+ options: DatabaseOptions = {},
229
+ environment: NodeJS.ProcessEnv =
230
+ process.env
231
+ ): Required<DatabaseOptions> {
232
+ const driver =
233
+ options.driver ??
234
+ normalizeDriver(
235
+ environment.DB_DRIVER ??
236
+ "mysql"
237
+ );
238
+
239
+ if (
240
+ driver !== "mysql"
241
+ ) {
242
+ throw new Error(
243
+ `BCP Database: unsupported database driver "${driver}".`
244
+ );
245
+ }
246
+
247
+ return {
248
+ driver,
249
+ host:
250
+ nonEmpty(
251
+ options.host ??
252
+ environment.DB_HOST
253
+ ) ??
254
+ "localhost",
255
+ port:
256
+ resolvePositiveInteger(
257
+ options.port ??
258
+ environment.DB_PORT,
259
+ 3306,
260
+ "DB_PORT"
261
+ ),
262
+ user:
263
+ nonEmpty(
264
+ options.user ??
265
+ environment.DB_USER
266
+ ) ??
267
+ "root",
268
+ password:
269
+ String(
270
+ options.password ??
271
+ environment.DB_PASSWORD ??
272
+ ""
273
+ ),
274
+ database:
275
+ nonEmpty(
276
+ options.database ??
277
+ environment.DB_NAME
278
+ ) ??
279
+ "bcp_app",
280
+ connectionLimit:
281
+ resolvePositiveInteger(
282
+ options.connectionLimit ??
283
+ environment.DB_CONNECTION_LIMIT,
284
+ 10,
285
+ "DB_CONNECTION_LIMIT"
286
+ ),
287
+ waitForConnections:
288
+ options.waitForConnections ??
289
+ resolveBoolean(
290
+ environment.DB_WAIT_FOR_CONNECTIONS,
291
+ true,
292
+ "DB_WAIT_FOR_CONNECTIONS"
293
+ ),
294
+ queueLimit:
295
+ resolveNonNegativeInteger(
296
+ options.queueLimit ??
297
+ environment.DB_QUEUE_LIMIT,
298
+ 0,
299
+ "DB_QUEUE_LIMIT"
300
+ ),
301
+ charset:
302
+ nonEmpty(
303
+ options.charset ??
304
+ environment.DB_CHARSET
305
+ ) ??
306
+ "utf8mb4",
307
+ };
308
+ }
309
+
310
+ export const db =
311
+ createDatabase();
312
+
313
+ async function createMysqlPool(
314
+ options: Required<DatabaseOptions>
315
+ ): Promise<MysqlPool> {
316
+ let mysqlModule:
317
+ MysqlModule;
318
+
319
+ try {
320
+ mysqlModule =
321
+ await import(
322
+ MYSQL_MODULE_SPECIFIER
323
+ ) as unknown as MysqlModule;
324
+ } catch (error) {
325
+ throw new Error(
326
+ "BCP Database: MySQL requires the optional dependency mysql2. Install it with `npm install mysql2` or create the app with the MySQL preset.",
327
+ {
328
+ cause:
329
+ error,
330
+ }
331
+ );
332
+ }
333
+
334
+ return mysqlModule.createPool({
335
+ host:
336
+ options.host,
337
+ port:
338
+ options.port,
339
+ user:
340
+ options.user,
341
+ password:
342
+ options.password,
343
+ database:
344
+ options.database,
345
+ waitForConnections:
346
+ options.waitForConnections,
347
+ connectionLimit:
348
+ options.connectionLimit,
349
+ queueLimit:
350
+ options.queueLimit,
351
+ charset:
352
+ options.charset,
353
+ });
354
+ }
355
+
356
+ function normalizeDriver(
357
+ value: string
358
+ ): DatabaseDriver {
359
+ const normalized =
360
+ value
361
+ .trim()
362
+ .toLowerCase();
363
+
364
+ if (
365
+ normalized === "mysql"
366
+ ) {
367
+ return "mysql";
368
+ }
369
+
370
+ throw new Error(
371
+ `BCP Database: unsupported database driver "${value}".`
372
+ );
373
+ }
374
+
375
+ function nonEmpty(
376
+ value: unknown
377
+ ): string | null {
378
+ if (
379
+ typeof value !== "string"
380
+ ) {
381
+ return null;
382
+ }
383
+
384
+ const normalized =
385
+ value.trim();
386
+
387
+ return normalized ||
388
+ null;
389
+ }
390
+
391
+ function resolvePositiveInteger(
392
+ value: unknown,
393
+ fallback: number,
394
+ label: string
395
+ ): number {
396
+ if (
397
+ value === undefined ||
398
+ value === null ||
399
+ value === ""
400
+ ) {
401
+ return fallback;
402
+ }
403
+
404
+ const parsed =
405
+ Number(value);
406
+
407
+ if (
408
+ !Number.isInteger(parsed) ||
409
+ parsed <= 0
410
+ ) {
411
+ throw new Error(
412
+ `BCP Database: ${label} must be a positive integer.`
413
+ );
414
+ }
415
+
416
+ return parsed;
417
+ }
418
+
419
+ function resolveNonNegativeInteger(
420
+ value: unknown,
421
+ fallback: number,
422
+ label: string
423
+ ): number {
424
+ if (
425
+ value === undefined ||
426
+ value === null ||
427
+ value === ""
428
+ ) {
429
+ return fallback;
430
+ }
431
+
432
+ const parsed =
433
+ Number(value);
434
+
435
+ if (
436
+ !Number.isInteger(parsed) ||
437
+ parsed < 0
438
+ ) {
439
+ throw new Error(
440
+ `BCP Database: ${label} must be a non-negative integer.`
441
+ );
442
+ }
443
+
444
+ return parsed;
445
+ }
446
+
447
+ function resolveBoolean(
448
+ value: unknown,
449
+ fallback: boolean,
450
+ label: string
451
+ ): boolean {
452
+ if (
453
+ value === undefined ||
454
+ value === null ||
455
+ value === ""
456
+ ) {
457
+ return fallback;
458
+ }
459
+
460
+ if (
461
+ value === true ||
462
+ value === "1" ||
463
+ value === "true"
464
+ ) {
465
+ return true;
466
+ }
467
+
468
+ if (
469
+ value === false ||
470
+ value === "0" ||
471
+ value === "false"
472
+ ) {
473
+ return false;
474
+ }
475
+
476
+ throw new Error(
477
+ `BCP Database: ${label} must be true/false or 1/0.`
478
+ );
479
+ }
480
+
481
+ function assertSql(
482
+ sql: string
483
+ ): void {
484
+ if (
485
+ typeof sql !== "string" ||
486
+ sql.trim() === ""
487
+ ) {
488
+ throw new TypeError(
489
+ "BCP Database: SQL must be a non-empty string."
490
+ );
491
+ }
492
+ }