@chidchanun/bcp 0.1.13 → 0.1.14

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,127 @@
1
+ # Database
2
+
3
+ BCP `0.1.14` adds the first framework-native database API through the server-only `bcp/database` entrypoint.
4
+
5
+ The initial adapter targets MySQL. Additional adapters can be added in later releases without changing the page, loader, guard or action APIs that consume the database layer.
6
+
7
+ ## Create an app with MySQL
8
+
9
+ ```bash
10
+ npx create-bcp-app my-app
11
+ ```
12
+
13
+ Choose **MySQL** during interactive setup. The generated project installs `mysql2`, writes the database environment variables and creates `lib/database.ts` as a thin wrapper around `bcp/database`.
14
+
15
+ ## Environment
16
+
17
+ ```env
18
+ DB_DRIVER=mysql
19
+ DB_HOST=localhost
20
+ DB_PORT=3306
21
+ DB_USER=root
22
+ DB_PASSWORD=
23
+ DB_NAME=bcp_app
24
+ DB_CONNECTION_LIMIT=10
25
+ DB_WAIT_FOR_CONNECTIONS=1
26
+ DB_QUEUE_LIMIT=0
27
+ DB_CHARSET=utf8mb4
28
+ ```
29
+
30
+ Only `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME` are required for the standard generated preset. The remaining values have safe framework defaults.
31
+
32
+ ## Query
33
+
34
+ ```ts
35
+ import {
36
+ db,
37
+ } from "bcp/database";
38
+
39
+ interface UserRow {
40
+ id: number;
41
+ email: string;
42
+ }
43
+
44
+ const users =
45
+ await db.query<UserRow[]>(
46
+ "SELECT id, email FROM users WHERE active = ?",
47
+ [
48
+ 1,
49
+ ]
50
+ );
51
+ ```
52
+
53
+ ## Execute
54
+
55
+ ```ts
56
+ const result =
57
+ await db.execute(
58
+ "INSERT INTO users (email) VALUES (?)",
59
+ [
60
+ "user@example.com",
61
+ ]
62
+ );
63
+ ```
64
+
65
+ Use placeholders and parameter arrays instead of concatenating untrusted values into SQL strings.
66
+
67
+ ## Transaction
68
+
69
+ ```ts
70
+ await db.transaction(
71
+ async (transaction) => {
72
+ await transaction.execute(
73
+ "UPDATE accounts SET balance = balance - ? WHERE id = ?",
74
+ [
75
+ 100,
76
+ 1,
77
+ ]
78
+ );
79
+
80
+ await transaction.execute(
81
+ "UPDATE accounts SET balance = balance + ? WHERE id = ?",
82
+ [
83
+ 100,
84
+ 2,
85
+ ]
86
+ );
87
+ }
88
+ );
89
+ ```
90
+
91
+ BCP commits the transaction when the callback resolves and rolls it back when the callback throws.
92
+
93
+ ## Custom database instance
94
+
95
+ ```ts
96
+ import {
97
+ createDatabase,
98
+ } from "bcp/database";
99
+
100
+ export const reportingDb =
101
+ createDatabase({
102
+ host:
103
+ "reporting-db.internal",
104
+ database:
105
+ "analytics",
106
+ connectionLimit:
107
+ 5,
108
+ });
109
+ ```
110
+
111
+ Explicit options override environment values for that database instance.
112
+
113
+ ## Lifecycle
114
+
115
+ Connections are lazy. Importing `bcp/database` does not open a MySQL connection. The pool is created on the first `query`, `execute` or `transaction` call.
116
+
117
+ For custom shutdown handling, close the pool with:
118
+
119
+ ```ts
120
+ await db.close();
121
+ ```
122
+
123
+ ## Server-only boundary
124
+
125
+ `bcp/database` is a server-only package export. Importing it into a browser bundle is blocked by the framework's browser export boundary.
126
+
127
+ The MySQL adapter loads `mysql2/promise` only when a connection is first needed. Projects that do not use MySQL do not need to install the driver.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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",
@@ -43,6 +43,11 @@
43
43
  "types": "./packages/client/src/config.ts",
44
44
  "default": "./packages/client/src/config.ts"
45
45
  },
46
+ "./database": {
47
+ "types": "./packages/client/src/database.ts",
48
+ "browser": "./packages/client/src/server-only.browser.mjs",
49
+ "default": "./packages/client/src/database.ts"
50
+ },
46
51
  "./server": {
47
52
  "types": "./packages/client/src/server.ts",
48
53
  "browser": "./packages/client/src/server-only.browser.mjs",
@@ -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
+ }