@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.
@@ -1,7 +1,21 @@
1
- export type DatabaseDriver = "mysql";
2
-
3
- export interface DatabaseOptions {
1
+ import {
2
+ createMysqlDatabaseAdapter,
3
+ } from "./database-mysql.js";
4
+ import {
5
+ createPostgresqlDatabaseAdapter,
6
+ } from "./database-postgresql.js";
7
+ import {
8
+ createSqliteDatabaseAdapter,
9
+ } from "./database-sqlite.js";
10
+
11
+ export type DatabaseDriver =
12
+ | "mysql"
13
+ | "postgresql"
14
+ | "sqlite";
15
+
16
+ export interface DatabaseConnectionOptions {
4
17
  driver?: DatabaseDriver;
18
+ url?: string;
5
19
  host?: string;
6
20
  port?: number;
7
21
  user?: string;
@@ -28,44 +42,62 @@ export interface TransactionDatabase {
28
42
  ): Promise<T>;
29
43
  }
30
44
 
31
- interface MysqlExecutor {
32
- query(
45
+ export interface ResolvedDatabaseOptions
46
+ extends Required<
47
+ Omit<
48
+ DatabaseConnectionOptions,
49
+ "url"
50
+ >
51
+ > {
52
+ url?: string;
53
+ }
54
+
55
+ export interface DatabaseAdapter {
56
+ readonly driver: string;
57
+ connect(): Promise<void>;
58
+ query<T = unknown>(
33
59
  sql: string,
34
60
  parameters?: DatabaseParameters
35
- ): Promise<[unknown, unknown]>;
36
- execute(
61
+ ): Promise<T>;
62
+ execute<T = unknown>(
37
63
  sql: string,
38
64
  parameters?: DatabaseParameters
39
- ): Promise<[unknown, unknown]>;
65
+ ): Promise<T>;
66
+ transaction<T>(
67
+ callback: (
68
+ database: TransactionDatabase
69
+ ) => Promise<T>
70
+ ): Promise<T>;
71
+ disconnect(): Promise<void>;
40
72
  }
41
73
 
42
- interface MysqlConnection extends MysqlExecutor {
43
- beginTransaction(): Promise<void>;
44
- commit(): Promise<void>;
45
- rollback(): Promise<void>;
46
- release(): void;
47
- }
74
+ export type DatabaseAdapterFactory =
75
+ () =>
76
+ | DatabaseAdapter
77
+ | Promise<DatabaseAdapter>;
48
78
 
49
- interface MysqlPool extends MysqlExecutor {
50
- getConnection(): Promise<MysqlConnection>;
51
- end(): Promise<void>;
79
+ export interface DatabaseOptions
80
+ extends DatabaseConnectionOptions {
81
+ adapter?:
82
+ | DatabaseAdapter
83
+ | DatabaseAdapterFactory;
52
84
  }
53
85
 
54
- interface MysqlModule {
55
- createPool(
56
- options: Record<string, unknown>
57
- ): MysqlPool;
86
+ interface DriverDefaults {
87
+ host: string;
88
+ port: number;
89
+ user: string;
90
+ database: string;
91
+ connectionLimit: number;
92
+ charset: string;
58
93
  }
59
94
 
60
- const MYSQL_MODULE_SPECIFIER =
61
- "mysql2/promise";
62
-
63
95
  export class BcpDatabase {
64
96
  private readonly options:
65
97
  DatabaseOptions;
66
98
 
67
- private poolPromise:
68
- Promise<MysqlPool> | null =
99
+ private adapterPromise:
100
+ Promise<DatabaseAdapter> | null =
69
101
  null;
70
102
 
71
103
  constructor(
@@ -76,38 +108,42 @@ export class BcpDatabase {
76
108
  };
77
109
  }
78
110
 
111
+ async connect(): Promise<void> {
112
+ await this.getAdapter();
113
+ }
114
+
79
115
  async query<T = unknown>(
80
116
  sql: string,
81
117
  parameters?: DatabaseParameters
82
118
  ): 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
- );
119
+ assertSql(
120
+ sql
121
+ );
122
+
123
+ const adapter =
124
+ await this.getAdapter();
92
125
 
93
- return rows as T;
126
+ return adapter.query<T>(
127
+ sql,
128
+ parameters
129
+ );
94
130
  }
95
131
 
96
132
  async execute<T = unknown>(
97
133
  sql: string,
98
134
  parameters?: DatabaseParameters
99
135
  ): 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
- );
136
+ assertSql(
137
+ sql
138
+ );
139
+
140
+ const adapter =
141
+ await this.getAdapter();
109
142
 
110
- return result as T;
143
+ return adapter.execute<T>(
144
+ sql,
145
+ parameters
146
+ );
111
147
  }
112
148
 
113
149
  async transaction<T>(
@@ -115,104 +151,63 @@ export class BcpDatabase {
115
151
  database: TransactionDatabase
116
152
  ) => Promise<T>
117
153
  ): 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
- );
154
+ assertTransactionCallback(
155
+ callback
156
+ );
171
157
 
172
- await connection.commit();
158
+ const adapter =
159
+ await this.getAdapter();
173
160
 
174
- return result;
175
- } catch (error) {
176
- try {
177
- await connection.rollback();
178
- } catch {
179
- // Preserve the original transaction error.
180
- }
161
+ return adapter.transaction(
162
+ callback
163
+ );
164
+ }
181
165
 
182
- throw error;
183
- } finally {
184
- connection.release();
185
- }
166
+ async disconnect(): Promise<void> {
167
+ await this.close();
186
168
  }
187
169
 
188
170
  async close(): Promise<void> {
189
- const pendingPool =
190
- this.poolPromise;
171
+ const pendingAdapter =
172
+ this.adapterPromise;
191
173
 
192
- this.poolPromise =
174
+ this.adapterPromise =
193
175
  null;
194
176
 
195
- if (!pendingPool) {
177
+ if (!pendingAdapter) {
196
178
  return;
197
179
  }
198
180
 
199
- const pool =
200
- await pendingPool;
181
+ let adapter:
182
+ DatabaseAdapter;
183
+
184
+ try {
185
+ adapter =
186
+ await pendingAdapter;
187
+ } catch {
188
+ return;
189
+ }
201
190
 
202
- await pool.end();
191
+ await adapter.disconnect();
203
192
  }
204
193
 
205
- private getPool(): Promise<MysqlPool> {
206
- if (!this.poolPromise) {
207
- this.poolPromise =
208
- createMysqlPool(
209
- resolveDatabaseOptions(
210
- this.options
211
- )
194
+ private async getAdapter():
195
+ Promise<DatabaseAdapter> {
196
+ if (!this.adapterPromise) {
197
+ this.adapterPromise =
198
+ initializeDatabaseAdapter(
199
+ this.options
212
200
  );
213
201
  }
214
202
 
215
- return this.poolPromise;
203
+ try {
204
+ return await this.adapterPromise;
205
+ } catch (error) {
206
+ this.adapterPromise =
207
+ null;
208
+
209
+ throw error;
210
+ }
216
211
  }
217
212
  }
218
213
 
@@ -225,38 +220,50 @@ export function createDatabase(
225
220
  }
226
221
 
227
222
  export function resolveDatabaseOptions(
228
- options: DatabaseOptions = {},
223
+ options: DatabaseConnectionOptions = {},
229
224
  environment: NodeJS.ProcessEnv =
230
225
  process.env
231
- ): Required<DatabaseOptions> {
226
+ ): ResolvedDatabaseOptions {
227
+ const url =
228
+ nonEmpty(
229
+ options.url ??
230
+ environment.DATABASE_URL
231
+ ) ??
232
+ undefined;
232
233
  const driver =
233
- options.driver ??
234
234
  normalizeDriver(
235
- environment.DB_DRIVER ??
236
- "mysql"
235
+ String(
236
+ options.driver ??
237
+ environment.DB_DRIVER ??
238
+ inferDriverFromDatabaseUrl(
239
+ url
240
+ ) ??
241
+ "mysql"
242
+ )
237
243
  );
238
-
239
- if (
240
- driver !== "mysql"
241
- ) {
242
- throw new Error(
243
- `BCP Database: unsupported database driver "${driver}".`
244
+ const defaults =
245
+ getDriverDefaults(
246
+ driver
244
247
  );
245
- }
246
248
 
247
249
  return {
248
250
  driver,
251
+ ...(url
252
+ ? {
253
+ url,
254
+ }
255
+ : {}),
249
256
  host:
250
257
  nonEmpty(
251
258
  options.host ??
252
259
  environment.DB_HOST
253
260
  ) ??
254
- "localhost",
261
+ defaults.host,
255
262
  port:
256
263
  resolvePositiveInteger(
257
264
  options.port ??
258
265
  environment.DB_PORT,
259
- 3306,
266
+ defaults.port,
260
267
  "DB_PORT"
261
268
  ),
262
269
  user:
@@ -264,7 +271,7 @@ export function resolveDatabaseOptions(
264
271
  options.user ??
265
272
  environment.DB_USER
266
273
  ) ??
267
- "root",
274
+ defaults.user,
268
275
  password:
269
276
  String(
270
277
  options.password ??
@@ -276,12 +283,12 @@ export function resolveDatabaseOptions(
276
283
  options.database ??
277
284
  environment.DB_NAME
278
285
  ) ??
279
- "bcp_app",
286
+ defaults.database,
280
287
  connectionLimit:
281
288
  resolvePositiveInteger(
282
289
  options.connectionLimit ??
283
290
  environment.DB_CONNECTION_LIMIT,
284
- 10,
291
+ defaults.connectionLimit,
285
292
  "DB_CONNECTION_LIMIT"
286
293
  ),
287
294
  waitForConnections:
@@ -303,54 +310,67 @@ export function resolveDatabaseOptions(
303
310
  options.charset ??
304
311
  environment.DB_CHARSET
305
312
  ) ??
306
- "utf8mb4",
313
+ defaults.charset,
307
314
  };
308
315
  }
309
316
 
310
317
  export const db =
311
318
  createDatabase();
312
319
 
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,
320
+ async function initializeDatabaseAdapter(
321
+ options: DatabaseOptions
322
+ ): Promise<DatabaseAdapter> {
323
+ let adapter:
324
+ DatabaseAdapter;
325
+
326
+ if (options.adapter) {
327
+ adapter =
328
+ typeof options.adapter ===
329
+ "function"
330
+ ? await options.adapter()
331
+ : options.adapter;
332
+ } else {
333
+ const resolvedOptions =
334
+ resolveDatabaseOptions(
335
+ options
336
+ );
337
+
338
+ switch (
339
+ resolvedOptions.driver
340
+ ) {
341
+ case "postgresql": {
342
+ adapter =
343
+ createPostgresqlDatabaseAdapter(
344
+ resolvedOptions
345
+ );
346
+ break;
330
347
  }
331
- );
348
+
349
+ case "sqlite": {
350
+ adapter =
351
+ createSqliteDatabaseAdapter(
352
+ resolvedOptions
353
+ );
354
+ break;
355
+ }
356
+
357
+ default: {
358
+ adapter =
359
+ createMysqlDatabaseAdapter(
360
+ resolvedOptions
361
+ );
362
+ break;
363
+ }
364
+ }
332
365
  }
333
366
 
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
- });
367
+ assertDatabaseAdapter(
368
+ adapter
369
+ );
370
+
371
+ await adapter.connect();
372
+
373
+ return adapter;
354
374
  }
355
375
 
356
376
  function normalizeDriver(
@@ -367,11 +387,121 @@ function normalizeDriver(
367
387
  return "mysql";
368
388
  }
369
389
 
390
+ if (
391
+ normalized === "postgresql" ||
392
+ normalized === "postgres" ||
393
+ normalized === "pg"
394
+ ) {
395
+ return "postgresql";
396
+ }
397
+
398
+ if (
399
+ normalized === "sqlite" ||
400
+ normalized === "sqlite3"
401
+ ) {
402
+ return "sqlite";
403
+ }
404
+
370
405
  throw new Error(
371
406
  `BCP Database: unsupported database driver "${value}".`
372
407
  );
373
408
  }
374
409
 
410
+ function inferDriverFromDatabaseUrl(
411
+ value: string | undefined
412
+ ): DatabaseDriver | null {
413
+ if (!value) {
414
+ return null;
415
+ }
416
+
417
+ if (
418
+ /^postgres(?:ql)?:\/\//i.test(
419
+ value
420
+ )
421
+ ) {
422
+ return "postgresql";
423
+ }
424
+
425
+ if (
426
+ /^mysql:\/\//i.test(
427
+ value
428
+ )
429
+ ) {
430
+ return "mysql";
431
+ }
432
+
433
+ if (
434
+ value === ":memory:" ||
435
+ /^(?:sqlite|file):/i.test(
436
+ value
437
+ ) ||
438
+ /\.(?:sqlite|sqlite3|db)$/i.test(
439
+ value
440
+ )
441
+ ) {
442
+ return "sqlite";
443
+ }
444
+
445
+ return null;
446
+ }
447
+
448
+ function getDriverDefaults(
449
+ driver: DatabaseDriver
450
+ ): DriverDefaults {
451
+ if (
452
+ driver === "postgresql"
453
+ ) {
454
+ return {
455
+ host:
456
+ "localhost",
457
+ port:
458
+ 5432,
459
+ user:
460
+ "postgres",
461
+ database:
462
+ "bcp_app",
463
+ connectionLimit:
464
+ 10,
465
+ charset:
466
+ "utf8",
467
+ };
468
+ }
469
+
470
+ if (
471
+ driver === "sqlite"
472
+ ) {
473
+ return {
474
+ host:
475
+ "",
476
+ port:
477
+ 0,
478
+ user:
479
+ "",
480
+ database:
481
+ "./data/bcp.sqlite",
482
+ connectionLimit:
483
+ 1,
484
+ charset:
485
+ "utf8",
486
+ };
487
+ }
488
+
489
+ return {
490
+ host:
491
+ "localhost",
492
+ port:
493
+ 3306,
494
+ user:
495
+ "root",
496
+ database:
497
+ "bcp_app",
498
+ connectionLimit:
499
+ 10,
500
+ charset:
501
+ "utf8mb4",
502
+ };
503
+ }
504
+
375
505
  function nonEmpty(
376
506
  value: unknown
377
507
  ): string | null {
@@ -490,3 +620,38 @@ function assertSql(
490
620
  );
491
621
  }
492
622
  }
623
+
624
+ function assertTransactionCallback(
625
+ callback: unknown
626
+ ): asserts callback is (
627
+ database: TransactionDatabase
628
+ ) => Promise<unknown> {
629
+ if (
630
+ typeof callback !==
631
+ "function"
632
+ ) {
633
+ throw new TypeError(
634
+ "BCP Database: transaction callback must be a function."
635
+ );
636
+ }
637
+ }
638
+
639
+ function assertDatabaseAdapter(
640
+ adapter: DatabaseAdapter
641
+ ): void {
642
+ if (
643
+ !adapter ||
644
+ typeof adapter !== "object" ||
645
+ typeof adapter.driver !== "string" ||
646
+ adapter.driver.trim() === "" ||
647
+ typeof adapter.connect !== "function" ||
648
+ typeof adapter.query !== "function" ||
649
+ typeof adapter.execute !== "function" ||
650
+ typeof adapter.transaction !== "function" ||
651
+ typeof adapter.disconnect !== "function"
652
+ ) {
653
+ throw new TypeError(
654
+ "BCP Database: adapter must implement driver, connect, query, execute, transaction and disconnect."
655
+ );
656
+ }
657
+ }