@chidchanun/bcp 0.2.1 → 0.2.3

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.
@@ -4,8 +4,10 @@ import {
4
4
  pathToFileURL,
5
5
  } from "node:url";
6
6
 
7
- import type {
8
- TransactionDatabase,
7
+ import {
8
+ resolveDatabaseOptions,
9
+ type DatabaseDriver,
10
+ type TransactionDatabase,
9
11
  } from "../../client/src/database.js";
10
12
 
11
13
  export type DatabaseMigrationAction =
@@ -35,6 +37,7 @@ export interface DatabaseMigrationCommandOptions {
35
37
  action: DatabaseMigrationAction;
36
38
  name?: string;
37
39
  database?: MigrationDatabase;
40
+ driver?: DatabaseDriver;
38
41
  now?: Date;
39
42
  }
40
43
 
@@ -65,6 +68,12 @@ interface MigrationModule {
65
68
  ) => Promise<void> | void;
66
69
  }
67
70
 
71
+ interface MigrationDialect {
72
+ createTableSql: string;
73
+ insertMigrationSql: string;
74
+ deleteMigrationSql: string;
75
+ }
76
+
68
77
  const MIGRATION_DIRECTORY =
69
78
  "migrations";
70
79
  const MIGRATION_TABLE =
@@ -105,8 +114,15 @@ export async function runDatabaseMigrationCommand(
105
114
  );
106
115
  }
107
116
 
117
+ const dialect =
118
+ getMigrationDialect(
119
+ options.driver ??
120
+ resolveDatabaseOptions().driver
121
+ );
122
+
108
123
  await ensureMigrationTable(
109
- database
124
+ database,
125
+ dialect
110
126
  );
111
127
 
112
128
  if (
@@ -129,14 +145,16 @@ export async function runDatabaseMigrationCommand(
129
145
  ) {
130
146
  await migratePending(
131
147
  rootDirectory,
132
- database
148
+ database,
149
+ dialect
133
150
  );
134
151
  return;
135
152
  }
136
153
 
137
154
  await rollbackLatestBatch(
138
155
  rootDirectory,
139
- database
156
+ database,
157
+ dialect
140
158
  );
141
159
  }
142
160
 
@@ -197,13 +215,13 @@ export function createMigrationFile(
197
215
  `export async function up(\n` +
198
216
  ` db: TransactionDatabase\n` +
199
217
  `): Promise<void> {\n` +
200
- ` // Write the forward migration here.\n` +
218
+ ` // Write provider-compatible forward SQL here.\n` +
201
219
  ` void db;\n` +
202
220
  `}\n\n` +
203
221
  `export async function down(\n` +
204
222
  ` db: TransactionDatabase\n` +
205
223
  `): Promise<void> {\n` +
206
- ` // Write the rollback migration here.\n` +
224
+ ` // Write provider-compatible rollback SQL here.\n` +
207
225
  ` void db;\n` +
208
226
  `}\n`;
209
227
 
@@ -363,7 +381,8 @@ export async function getMigrationStatus(
363
381
 
364
382
  async function migratePending(
365
383
  rootDirectory: string,
366
- database: MigrationDatabase
384
+ database: MigrationDatabase,
385
+ dialect: MigrationDialect
367
386
  ): Promise<void> {
368
387
  const files =
369
388
  listMigrationFiles(
@@ -421,11 +440,11 @@ async function migratePending(
421
440
 
422
441
  await database.transaction(
423
442
  async (transaction) => {
424
- await module.up?.(
443
+ await module.up(
425
444
  transaction
426
445
  );
427
446
  await transaction.execute(
428
- `INSERT INTO ${MIGRATION_TABLE} (name, batch) VALUES (?, ?)`,
447
+ dialect.insertMigrationSql,
429
448
  [
430
449
  migration.name,
431
450
  batch,
@@ -442,7 +461,8 @@ async function migratePending(
442
461
 
443
462
  async function rollbackLatestBatch(
444
463
  rootDirectory: string,
445
- database: MigrationDatabase
464
+ database: MigrationDatabase,
465
+ dialect: MigrationDialect
446
466
  ): Promise<void> {
447
467
  const applied =
448
468
  await readAppliedMigrations(
@@ -520,11 +540,11 @@ async function rollbackLatestBatch(
520
540
 
521
541
  await database.transaction(
522
542
  async (transaction) => {
523
- await module.down?.(
543
+ await module.down(
524
544
  transaction
525
545
  );
526
546
  await transaction.execute(
527
- `DELETE FROM ${MIGRATION_TABLE} WHERE name = ?`,
547
+ dialect.deleteMigrationSql,
528
548
  [
529
549
  record.name,
530
550
  ]
@@ -539,15 +559,11 @@ async function rollbackLatestBatch(
539
559
  }
540
560
 
541
561
  async function ensureMigrationTable(
542
- database: MigrationDatabase
562
+ database: MigrationDatabase,
563
+ dialect: MigrationDialect
543
564
  ): Promise<void> {
544
565
  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`
566
+ dialect.createTableSql
551
567
  );
552
568
  }
553
569
 
@@ -561,6 +577,60 @@ async function readAppliedMigrations(
561
577
  );
562
578
  }
563
579
 
580
+ function getMigrationDialect(
581
+ driver: DatabaseDriver
582
+ ): MigrationDialect {
583
+ if (
584
+ driver === "postgresql"
585
+ ) {
586
+ return {
587
+ createTableSql:
588
+ `CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (` +
589
+ `id BIGSERIAL PRIMARY KEY, ` +
590
+ `name VARCHAR(255) NOT NULL UNIQUE, ` +
591
+ `batch INTEGER NOT NULL, ` +
592
+ `applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP` +
593
+ `)`,
594
+ insertMigrationSql:
595
+ `INSERT INTO ${MIGRATION_TABLE} (name, batch) VALUES ($1, $2)`,
596
+ deleteMigrationSql:
597
+ `DELETE FROM ${MIGRATION_TABLE} WHERE name = $1`,
598
+ };
599
+ }
600
+
601
+ if (
602
+ driver === "sqlite"
603
+ ) {
604
+ return {
605
+ createTableSql:
606
+ `CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (` +
607
+ `id INTEGER PRIMARY KEY AUTOINCREMENT, ` +
608
+ `name TEXT NOT NULL UNIQUE, ` +
609
+ `batch INTEGER NOT NULL, ` +
610
+ `applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +
611
+ `)`,
612
+ insertMigrationSql:
613
+ `INSERT INTO ${MIGRATION_TABLE} (name, batch) VALUES (?, ?)`,
614
+ deleteMigrationSql:
615
+ `DELETE FROM ${MIGRATION_TABLE} WHERE name = ?`,
616
+ };
617
+ }
618
+
619
+ return {
620
+ createTableSql:
621
+ `CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (` +
622
+ `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, ` +
623
+ `name VARCHAR(255) NOT NULL UNIQUE, ` +
624
+ `batch INT UNSIGNED NOT NULL, ` +
625
+ `applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` +
626
+ `) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
627
+ insertMigrationSql:
628
+ `INSERT INTO ${MIGRATION_TABLE} (name, batch) VALUES (?, ?)`,
629
+ deleteMigrationSql:
630
+ `DELETE FROM ${MIGRATION_TABLE} WHERE name = ?`,
631
+ };
632
+ }
633
+
564
634
  async function loadMigrationModule(
565
635
  migration: LoadedMigration
566
636
  ): Promise<Required<MigrationModule>> {
@@ -595,7 +665,7 @@ async function loadMigrationModule(
595
665
  module.up,
596
666
  down:
597
667
  module.down,
598
- };
668
+ } as Required<MigrationModule>;
599
669
  }
600
670
 
601
671
  function normalizeMigrationName(
@@ -76,6 +76,11 @@ switch (cliOptions.command) {
76
76
  break;
77
77
  }
78
78
 
79
+ case "config": {
80
+ await runConfigCommand();
81
+ break;
82
+ }
83
+
79
84
  case "doctor": {
80
85
  await runDeveloperCommand(
81
86
  "doctor"
@@ -112,6 +117,24 @@ async function runDev() {
112
117
  cliOptions.rootDirectory
113
118
  );
114
119
 
120
+ const {
121
+ runStartupConfigurationDiagnostics,
122
+ } =
123
+ await import(
124
+ "./configuration.js"
125
+ );
126
+
127
+ await runStartupConfigurationDiagnostics(
128
+ rootDirectory,
129
+ "development",
130
+ {
131
+ port:
132
+ cliOptions.port,
133
+ hostname:
134
+ cliOptions.hostname,
135
+ }
136
+ );
137
+
115
138
  installApplicationModuleAlias(
116
139
  rootDirectory
117
140
  );
@@ -185,6 +208,24 @@ async function runBuild() {
185
208
  cliOptions.rootDirectory
186
209
  );
187
210
 
211
+ const {
212
+ runStartupConfigurationDiagnostics,
213
+ } =
214
+ await import(
215
+ "./configuration.js"
216
+ );
217
+
218
+ await runStartupConfigurationDiagnostics(
219
+ rootDirectory,
220
+ "production",
221
+ {
222
+ port:
223
+ cliOptions.port,
224
+ hostname:
225
+ cliOptions.hostname,
226
+ }
227
+ );
228
+
188
229
  const appDirectory =
189
230
  path.join(
190
231
  rootDirectory,
@@ -518,6 +559,36 @@ async function runGenerateCommand(): Promise<void> {
518
559
  });
519
560
  }
520
561
 
562
+ async function runConfigCommand(): Promise<void> {
563
+ const action =
564
+ cliOptions.configAction;
565
+
566
+ if (
567
+ action === undefined ||
568
+ action === "help"
569
+ ) {
570
+ printConfigHelp();
571
+ return;
572
+ }
573
+
574
+ const rootDirectory =
575
+ resolveProjectRoot(
576
+ cliOptions.rootDirectory
577
+ );
578
+ const {
579
+ runConfigurationCheck,
580
+ } =
581
+ await import(
582
+ "./configuration.js"
583
+ );
584
+
585
+ await runConfigurationCheck({
586
+ rootDirectory,
587
+ json:
588
+ cliOptions.json,
589
+ });
590
+ }
591
+
521
592
  async function runDeveloperCommand(
522
593
  command:
523
594
  "doctor" |
@@ -759,6 +830,32 @@ Examples:
759
830
  `);
760
831
  }
761
832
 
833
+ function printConfigHelp() {
834
+ console.log(`
835
+ BCP Configuration & Environment
836
+
837
+ Usage:
838
+ bcp config <command> [options]
839
+
840
+ Commands:
841
+ check Validate bcp.config.*, bcp.environment.* and loaded environment values
842
+ help Show configuration command help
843
+
844
+ Options:
845
+ --root <path> Project root directory
846
+ --json Emit the configuration diagnostics report as JSON
847
+
848
+ Environment mode:
849
+ NODE_ENV=production bcp config check
850
+ NODE_ENV=test bcp config check
851
+ bcp config check (development by default)
852
+
853
+ Examples:
854
+ bcp config check
855
+ bcp config check --json
856
+ `);
857
+ }
858
+
762
859
  function printHelp() {
763
860
  console.log(`
764
861
  BCP Framework v${FRAMEWORK_VERSION}
@@ -774,6 +871,7 @@ Commands:
774
871
  update [target] Update BCP Framework (default target: latest)
775
872
  db <command> Manage database migrations
776
873
  generate <kind> Generate pages, API routes, middleware or migrations
874
+ config <command> Validate project configuration and environment schema
777
875
  doctor Run project/runtime health diagnostics
778
876
  inspect Print resolved env, config, dependencies and routes
779
877
  help Show this help message
@@ -786,7 +884,7 @@ Options:
786
884
  --host <host> Alias for --hostname
787
885
  --check Check for a BCP update without changing files
788
886
  --dry-run Preview a BCP update without changing files
789
- --json JSON output for doctor/inspect
887
+ --json JSON output for doctor/inspect/config
790
888
  --force Replace existing generated scaffold files
791
889
  -h, --help Show help
792
890
  -v, --version Show version
@@ -803,6 +901,8 @@ Examples:
803
901
  bcp generate page dashboard/users
804
902
  bcp generate api users
805
903
  bcp generate middleware
904
+ bcp config check
905
+ bcp config check --json
806
906
  bcp doctor
807
907
  bcp doctor --json
808
908
  bcp inspect
@@ -16,3 +16,29 @@ export {
16
16
  type ResolveBcpConfigOverrides,
17
17
  type ResolvedBcpConfig,
18
18
  } from "../../config/src/index.js";
19
+
20
+ export {
21
+ applyEnvironmentDefaults,
22
+ defineEnvironment,
23
+ validateEnvironment,
24
+ type BcpEnvironmentSchema,
25
+ type BcpEnvironmentValidationIssue,
26
+ type BcpEnvironmentValidationResult,
27
+ type BcpEnvironmentValueType,
28
+ type BcpEnvironmentVariableRule,
29
+ } from "../../config/src/environment-schema.js";
30
+
31
+ export {
32
+ getEnvironmentSchemaFileNames,
33
+ loadBcpEnvironmentSchema,
34
+ type LoadedBcpEnvironmentSchema,
35
+ } from "../../config/src/environment-loader.js";
36
+
37
+ export {
38
+ assertConfigurationDiagnostics,
39
+ diagnoseBcpConfiguration,
40
+ type BcpConfigurationDiagnostic,
41
+ type BcpConfigurationDiagnosticSeverity,
42
+ type BcpConfigurationDiagnosticsReport,
43
+ type DiagnoseBcpConfigurationOptions,
44
+ } from "../../config/src/diagnostics.js";
@@ -0,0 +1,291 @@
1
+ import type {
2
+ DatabaseAdapter,
3
+ DatabaseParameters,
4
+ ResolvedDatabaseOptions,
5
+ TransactionDatabase,
6
+ } from "./database.js";
7
+
8
+ interface MysqlExecutor {
9
+ query(
10
+ sql: string,
11
+ parameters?: DatabaseParameters
12
+ ): Promise<[unknown, unknown]>;
13
+ execute(
14
+ sql: string,
15
+ parameters?: DatabaseParameters
16
+ ): Promise<[unknown, unknown]>;
17
+ }
18
+
19
+ interface MysqlConnection extends MysqlExecutor {
20
+ beginTransaction(): Promise<void>;
21
+ commit(): Promise<void>;
22
+ rollback(): Promise<void>;
23
+ release(): void;
24
+ }
25
+
26
+ interface MysqlPool extends MysqlExecutor {
27
+ getConnection(): Promise<MysqlConnection>;
28
+ end(): Promise<void>;
29
+ }
30
+
31
+ interface MysqlModule {
32
+ createPool(
33
+ options: Record<string, unknown>
34
+ ): MysqlPool;
35
+ }
36
+
37
+ const MYSQL_MODULE_SPECIFIER =
38
+ "mysql2/promise";
39
+
40
+ export function createMysqlDatabaseAdapter(
41
+ options: ResolvedDatabaseOptions
42
+ ): DatabaseAdapter {
43
+ return new MysqlDatabaseAdapter(
44
+ options
45
+ );
46
+ }
47
+
48
+ class MysqlDatabaseAdapter
49
+ implements DatabaseAdapter {
50
+ readonly driver = "mysql";
51
+
52
+ private readonly options:
53
+ ResolvedDatabaseOptions;
54
+
55
+ private poolPromise:
56
+ Promise<MysqlPool> | null =
57
+ null;
58
+
59
+ constructor(
60
+ options: ResolvedDatabaseOptions
61
+ ) {
62
+ this.options =
63
+ options;
64
+ }
65
+
66
+ async connect(): Promise<void> {
67
+ if (!this.poolPromise) {
68
+ this.poolPromise =
69
+ createMysqlPool(
70
+ this.options
71
+ );
72
+ }
73
+
74
+ await this.poolPromise;
75
+ }
76
+
77
+ async query<T = unknown>(
78
+ sql: string,
79
+ parameters?: DatabaseParameters
80
+ ): Promise<T> {
81
+ assertSql(
82
+ sql
83
+ );
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(
101
+ sql
102
+ );
103
+
104
+ const pool =
105
+ await this.getPool();
106
+ const [result] =
107
+ await pool.execute(
108
+ sql,
109
+ parameters
110
+ );
111
+
112
+ return result as T;
113
+ }
114
+
115
+ async transaction<T>(
116
+ callback: (
117
+ database: TransactionDatabase
118
+ ) => Promise<T>
119
+ ): Promise<T> {
120
+ assertTransactionCallback(
121
+ callback
122
+ );
123
+
124
+ const pool =
125
+ await this.getPool();
126
+ const connection =
127
+ await pool.getConnection();
128
+
129
+ await connection.beginTransaction();
130
+
131
+ const transactionDatabase:
132
+ TransactionDatabase = {
133
+ query: async <R = unknown>(
134
+ sql: string,
135
+ parameters?: DatabaseParameters
136
+ ): Promise<R> => {
137
+ assertSql(
138
+ sql
139
+ );
140
+
141
+ const [rows] =
142
+ await connection.query(
143
+ sql,
144
+ parameters
145
+ );
146
+
147
+ return rows as R;
148
+ },
149
+ execute: async <R = unknown>(
150
+ sql: string,
151
+ parameters?: DatabaseParameters
152
+ ): Promise<R> => {
153
+ assertSql(
154
+ sql
155
+ );
156
+
157
+ const [result] =
158
+ await connection.execute(
159
+ sql,
160
+ parameters
161
+ );
162
+
163
+ return result as R;
164
+ },
165
+ };
166
+
167
+ try {
168
+ const result =
169
+ await callback(
170
+ transactionDatabase
171
+ );
172
+
173
+ await connection.commit();
174
+
175
+ return result;
176
+ } catch (error) {
177
+ try {
178
+ await connection.rollback();
179
+ } catch {
180
+ // Preserve the original transaction error.
181
+ }
182
+
183
+ throw error;
184
+ } finally {
185
+ connection.release();
186
+ }
187
+ }
188
+
189
+ async disconnect(): Promise<void> {
190
+ const pendingPool =
191
+ this.poolPromise;
192
+
193
+ this.poolPromise =
194
+ null;
195
+
196
+ if (!pendingPool) {
197
+ return;
198
+ }
199
+
200
+ let pool:
201
+ MysqlPool;
202
+
203
+ try {
204
+ pool =
205
+ await pendingPool;
206
+ } catch {
207
+ return;
208
+ }
209
+
210
+ await pool.end();
211
+ }
212
+
213
+ private async getPool():
214
+ Promise<MysqlPool> {
215
+ await this.connect();
216
+
217
+ return this.poolPromise as
218
+ Promise<MysqlPool>;
219
+ }
220
+ }
221
+
222
+ async function createMysqlPool(
223
+ options: ResolvedDatabaseOptions
224
+ ): Promise<MysqlPool> {
225
+ let mysqlModule:
226
+ MysqlModule;
227
+
228
+ try {
229
+ mysqlModule =
230
+ await import(
231
+ MYSQL_MODULE_SPECIFIER
232
+ ) as unknown as MysqlModule;
233
+ } catch (error) {
234
+ throw new Error(
235
+ "BCP Database: MySQL requires the optional dependency mysql2. Install it with `npm install mysql2` or create the app with the MySQL preset.",
236
+ {
237
+ cause:
238
+ error,
239
+ }
240
+ );
241
+ }
242
+
243
+ return mysqlModule.createPool({
244
+ host:
245
+ options.host,
246
+ port:
247
+ options.port,
248
+ user:
249
+ options.user,
250
+ password:
251
+ options.password,
252
+ database:
253
+ options.database,
254
+ waitForConnections:
255
+ options.waitForConnections,
256
+ connectionLimit:
257
+ options.connectionLimit,
258
+ queueLimit:
259
+ options.queueLimit,
260
+ charset:
261
+ options.charset,
262
+ });
263
+ }
264
+
265
+ function assertSql(
266
+ sql: string
267
+ ): void {
268
+ if (
269
+ typeof sql !== "string" ||
270
+ sql.trim() === ""
271
+ ) {
272
+ throw new TypeError(
273
+ "BCP Database: SQL must be a non-empty string."
274
+ );
275
+ }
276
+ }
277
+
278
+ function assertTransactionCallback(
279
+ callback: unknown
280
+ ): asserts callback is (
281
+ database: TransactionDatabase
282
+ ) => Promise<unknown> {
283
+ if (
284
+ typeof callback !==
285
+ "function"
286
+ ) {
287
+ throw new TypeError(
288
+ "BCP Database: transaction callback must be a function."
289
+ );
290
+ }
291
+ }