@seip/blue-bird 0.7.6 → 0.9.0

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.
Files changed (40) hide show
  1. package/.env_example +34 -34
  2. package/AGENTS.md +174 -249
  3. package/LICENSE +21 -21
  4. package/README.md +331 -367
  5. package/{index.js → backend/index.js} +22 -30
  6. package/backend/routes/api.js +57 -57
  7. package/core/app.js +338 -402
  8. package/core/auth.js +262 -256
  9. package/core/cache.js +174 -174
  10. package/core/cli/docker.js +488 -457
  11. package/core/cli/init.js +333 -337
  12. package/core/cli/route.js +42 -42
  13. package/core/config.js +52 -52
  14. package/core/database.js +263 -263
  15. package/core/debug.js +248 -248
  16. package/core/logger.js +115 -115
  17. package/core/middleware.js +27 -27
  18. package/core/router.js +144 -144
  19. package/core/swagger.js +40 -40
  20. package/core/upload.js +77 -77
  21. package/core/validate.js +380 -380
  22. package/docker/Dockerfile +16 -16
  23. package/docker/docker-compose.dev.yml +6 -0
  24. package/docker/docker-compose.mysql.yml +92 -92
  25. package/docker/docker-compose.none.yml +68 -68
  26. package/docker/docker-compose.postgres.yml +93 -93
  27. package/docker/nginx.conf +98 -106
  28. package/docker-compose.yml +92 -92
  29. package/frontend/about.html +103 -0
  30. package/frontend/images/favicon.ico +0 -0
  31. package/frontend/index.html +141 -0
  32. package/frontend/js/tailwind.js +8 -0
  33. package/package.json +64 -71
  34. package/frontend/astro.config.mjs +0 -35
  35. package/frontend/public/css/app.css +0 -319
  36. package/frontend/public/favicon.ico +0 -0
  37. package/frontend/src/http/api.js +0 -29
  38. package/frontend/src/layouts/Layout.astro +0 -20
  39. package/frontend/src/pages/about.astro +0 -54
  40. package/frontend/src/pages/index.astro +0 -110
package/core/database.js CHANGED
@@ -1,263 +1,263 @@
1
- import crypto from "node:crypto";
2
- import { getRedisClient } from "./cache.js";
3
-
4
- let DB_TYPE = (process.env.DB_TYPE || "").toLowerCase();
5
-
6
- if (
7
- !DB_TYPE &&
8
- process.env.DATABASE_URL &&
9
- !process.env.DATABASE_URL.startsWith("#")
10
- ) {
11
- if (
12
- process.env.DATABASE_URL.startsWith("postgres://") ||
13
- process.env.DATABASE_URL.startsWith("postgresql://")
14
- ) {
15
- DB_TYPE = "postgres";
16
- } else if (process.env.DATABASE_URL.startsWith("mysql://")) {
17
- DB_TYPE = "mysql";
18
- }
19
- }
20
- if (!DB_TYPE) {
21
- DB_TYPE = "mysql";
22
- }
23
-
24
- let mysqlPromise = null;
25
- let pgPromise = null;
26
-
27
- if (DB_TYPE === "postgres") {
28
- try {
29
- pgPromise = await import("pg");
30
- } catch (err) {
31
- console.error(
32
- "[DATABASE ERROR] pg package is not installed. Database wrapper is disabled.",
33
- );
34
- }
35
- } else if (DB_TYPE === "mysql") {
36
- try {
37
- mysqlPromise = await import("mysql2/promise");
38
- } catch (err) {
39
- console.error(
40
- "[DATABASE ERROR] mysql2 package is not installed. Database wrapper is disabled.",
41
- );
42
- }
43
- }
44
-
45
- /**
46
- * Database class wrapping mysql2 and pg with reconnection retries, connection pooling, and query caching.
47
- */
48
- class Database {
49
- /**
50
- * Initializes config from DATABASE_URL or DB_* environment variables.
51
- * For default Database use .env DB_HOST, DB_USER, DB_PASSWORD...
52
- * @param {number} [connectionLimit=10] - Maximum number of connections in the pool.
53
- * @param {number} [queueLimit=0] - Maximum number of queued connections.
54
- * @param {Object} [config={}] - Additional configuration options: DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT, DB_TYPE.
55
- * @example const connection = new Database(10, 0, { DB_HOST: "localhost", DB_USER: "root", DB_PASSWORD: "password", DB_NAME: "blue_bird", DB_PORT: 3306, DB_TYPE: "mysql" });
56
- */
57
- constructor(connectionLimit = 10, queueLimit = 0, config = {}) {
58
- this.pool = null;
59
- this.type = DB_TYPE;
60
-
61
- this.config = {
62
- ...config,
63
- host: process.env.DB_HOST || "localhost",
64
- user:
65
- process.env.DB_USER || (this.type === "postgres" ? "postgres" : "root"),
66
- password: process.env.DB_PASSWORD || "root",
67
- database: process.env.DB_NAME || "blue_bird",
68
- port:
69
- parseInt(process.env.DB_PORT) ||
70
- (this.type === "postgres" ? 5432 : 3306),
71
- charset: "utf8mb4",
72
- waitForConnections: true,
73
- connectionLimit: connectionLimit,
74
- queueLimit: queueLimit,
75
- max: connectionLimit,
76
- };
77
-
78
- if (process.env.DATABASE_URL && !process.env.DATABASE_URL.startsWith("#")) {
79
- try {
80
- if (
81
- process.env.DATABASE_URL.startsWith("mysql://") ||
82
- process.env.DATABASE_URL.startsWith("postgres://") ||
83
- process.env.DATABASE_URL.startsWith("postgresql://")
84
- ) {
85
- const url = new URL(process.env.DATABASE_URL);
86
- this.config.host = url.hostname;
87
- this.config.port =
88
- parseInt(url.port) || (this.type === "postgres" ? 5432 : 3306);
89
- this.config.user = url.username;
90
- this.config.password = url.password;
91
- this.config.database = url.pathname.substring(1);
92
- this.config.connectionString = process.env.DATABASE_URL;
93
- }
94
- } catch (err) {
95
- console.error(
96
- "[DATABASE ERROR] Failed to parse DATABASE_URL:",
97
- err.message,
98
- );
99
- }
100
- }
101
- }
102
-
103
- /**
104
- * Creates the database connection pool with 3 retry attempts on failure.
105
- * @param {number} [retries=3] - Number of connection attempts.
106
- * @returns {Promise<boolean>} True if connection pool was created.
107
- */
108
- async init(retries = 3) {
109
- if (!mysqlPromise && !pgPromise) return false;
110
- if (this.pool) return true;
111
-
112
- for (let attempt = 1; attempt <= retries; attempt++) {
113
- try {
114
- if (this.type === "postgres" && pgPromise) {
115
- const pg = pgPromise.default || pgPromise;
116
- this.pool = new pg.Pool(this.config);
117
- await this.pool.query("SELECT 1");
118
- } else if (mysqlPromise) {
119
- const { max, connectionString, ...mysqlConfig } = this.config;
120
- this.pool = mysqlPromise.createPool(mysqlConfig);
121
- await this.pool.query("SELECT 1");
122
- }
123
- return true;
124
- } catch (err) {
125
- this.pool = null;
126
- if (attempt === retries) {
127
- console.error(
128
- `[DATABASE ERROR] Connection failed after ${retries} attempts:`,
129
- err.message,
130
- );
131
- return false;
132
- }
133
- await new Promise((resolve) => setTimeout(resolve, 1000));
134
- }
135
- }
136
- return false;
137
- }
138
-
139
- /**
140
- * Runs a SQL query with parameters and formatting options.
141
- * Supports both MySQL and PostgreSQL (converting ? to $1, $2 for Postgres automatically).
142
- *
143
- * @param {string} sql - SQL query string.
144
- * @param {Array} [params=[]] - Query parameter array.
145
- * @param {Object|string} [options={}] - Query options. Supports 'return_row', 'return_rows', and 'cache' (seconds).
146
- * @returns {Promise<*>| int | boolean} Formatted query result or false on error, or insert id of insert query.
147
- * @example const result = await connection.query("SELECT * FROM users WHERE id = ?", [1], "return_row");
148
- * @example const result = await connection.query("SELECT * FROM users WHERE id = ?", [1], { cache: 60 });
149
- * @example const result = await connection.query("SELECT * FROM users WHERE id = ?", [1], { debug: true });
150
- * @example const insert_id = await connection.query("INSERT INTO users (name, email, password) VALUES (?, ?, ?)", ["John Doe", "john@example.com", "password"]);
151
- */
152
- async query(sql, params = [], options = {}) {
153
- if (!mysqlPromise && !pgPromise) return false;
154
- if (!this.pool) {
155
- const initialized = await this.init();
156
- if (!initialized) return false;
157
- }
158
-
159
- const queryOptions =
160
- typeof options === "string" ? { [options]: true } : options;
161
- const cleanSql = sql.trim();
162
- const isSelect = cleanSql.toLowerCase().startsWith("select");
163
- const isInsert = cleanSql.toLowerCase().startsWith("insert");
164
-
165
- const redisClient = getRedisClient();
166
- let cacheKey = null;
167
- const isDebug = queryOptions.debug ?? false;
168
- if (isDebug) {
169
- console.log("[DATABASE DEBUG] SQL:", sql);
170
- console.log("[DATABASE DEBUG] PARAMS:", params);
171
- console.log("[DATABASE DEBUG] OPTIONS:", options);
172
- }
173
-
174
- if (isSelect && queryOptions.cache && redisClient) {
175
- const hash = crypto
176
- .createHash("md5")
177
- .update(cleanSql + JSON.stringify(params))
178
- .digest("hex");
179
- cacheKey = `db:${hash}`;
180
- try {
181
- if (isDebug) {
182
- console.log("[DATABASE DEBUG][Redis] CACHE KEY:", cacheKey);
183
- }
184
- const cached = await redisClient.get(cacheKey);
185
- if (cached) {
186
- if (isDebug) {
187
- console.log("[DATABASE DEBUG][Redis] CACHE HIT");
188
- }
189
- return JSON.parse(cached);
190
- } else {
191
- if (isDebug) {
192
- console.log("[DATABASE DEBUG][Redis] CACHE MISS");
193
- }
194
- }
195
- } catch (err) {
196
- console.error(
197
- "[DATABASE ERROR] Failed to get cached data:",
198
- err.message,
199
- );
200
- }
201
- }
202
-
203
- try {
204
- if (this.type === "postgres" && pgPromise) {
205
- let paramIndex = 1;
206
- const pgSql = cleanSql.replace(/\?/g, () => `$${paramIndex++}`);
207
- const res = await this.pool.query(pgSql, params);
208
-
209
- if (isSelect) {
210
- const rows = res.rows || [];
211
- if (cacheKey && queryOptions.cache && redisClient) {
212
- await redisClient
213
- .set(cacheKey, JSON.stringify(rows), {
214
- EX: parseInt(queryOptions.cache),
215
- })
216
- .catch(() => {});
217
- }
218
- if (queryOptions.return_row) {
219
- return rows.length > 0 ? rows[0] : null;
220
- }
221
- return rows;
222
- }
223
-
224
- if (isInsert) {
225
- if (res.rows && res.rows.length > 0) {
226
- return res.rows[0].id || res.rows[0];
227
- }
228
- return res.rowCount;
229
- }
230
-
231
- return res.rowCount;
232
- } else {
233
- const [results] = await this.pool.execute(cleanSql, params);
234
-
235
- if (isSelect) {
236
- const rows = Array.isArray(results) ? results : [];
237
- if (cacheKey && queryOptions.cache && redisClient) {
238
- await redisClient
239
- .set(cacheKey, JSON.stringify(rows), {
240
- EX: parseInt(queryOptions.cache),
241
- })
242
- .catch(() => {});
243
- }
244
- if (queryOptions.return_row) {
245
- return rows.length > 0 ? rows[0] : null;
246
- }
247
- return rows;
248
- }
249
-
250
- if (isInsert) {
251
- return results.insertId || results;
252
- }
253
-
254
- return results;
255
- }
256
- } catch (err) {
257
- console.error("[DATABASE ERROR] Query execution failed:", err.message);
258
- throw err;
259
- }
260
- }
261
- }
262
-
263
- export { Database, DB_TYPE };
1
+ import crypto from "node:crypto";
2
+ import { getRedisClient } from "./cache.js";
3
+
4
+ let DB_TYPE = (process.env.DB_TYPE || "").toLowerCase();
5
+
6
+ if (
7
+ !DB_TYPE &&
8
+ process.env.DATABASE_URL &&
9
+ !process.env.DATABASE_URL.startsWith("#")
10
+ ) {
11
+ if (
12
+ process.env.DATABASE_URL.startsWith("postgres://") ||
13
+ process.env.DATABASE_URL.startsWith("postgresql://")
14
+ ) {
15
+ DB_TYPE = "postgres";
16
+ } else if (process.env.DATABASE_URL.startsWith("mysql://")) {
17
+ DB_TYPE = "mysql";
18
+ }
19
+ }
20
+ if (!DB_TYPE) {
21
+ DB_TYPE = "mysql";
22
+ }
23
+
24
+ let mysqlPromise = null;
25
+ let pgPromise = null;
26
+
27
+ if (DB_TYPE === "postgres") {
28
+ try {
29
+ pgPromise = await import("pg");
30
+ } catch (err) {
31
+ console.error(
32
+ "[DATABASE ERROR] pg package is not installed. Database wrapper is disabled.",
33
+ );
34
+ }
35
+ } else if (DB_TYPE === "mysql") {
36
+ try {
37
+ mysqlPromise = await import("mysql2/promise");
38
+ } catch (err) {
39
+ console.error(
40
+ "[DATABASE ERROR] mysql2 package is not installed. Database wrapper is disabled.",
41
+ );
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Database class wrapping mysql2 and pg with reconnection retries, connection pooling, and query caching.
47
+ */
48
+ class Database {
49
+ /**
50
+ * Initializes config from DATABASE_URL or DB_* environment variables.
51
+ * For default Database use .env DB_HOST, DB_USER, DB_PASSWORD...
52
+ * @param {number} [connectionLimit=10] - Maximum number of connections in the pool.
53
+ * @param {number} [queueLimit=0] - Maximum number of queued connections.
54
+ * @param {Object} [config={}] - Additional configuration options: DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT, DB_TYPE.
55
+ * @example const connection = new Database(10, 0, { DB_HOST: "localhost", DB_USER: "root", DB_PASSWORD: "password", DB_NAME: "blue_bird", DB_PORT: 3306, DB_TYPE: "mysql" });
56
+ */
57
+ constructor(connectionLimit = 10, queueLimit = 0, config = {}) {
58
+ this.pool = null;
59
+ this.type = DB_TYPE;
60
+
61
+ this.config = {
62
+ ...config,
63
+ host: process.env.DB_HOST || "localhost",
64
+ user:
65
+ process.env.DB_USER || (this.type === "postgres" ? "postgres" : "root"),
66
+ password: process.env.DB_PASSWORD || "root",
67
+ database: process.env.DB_NAME || "blue_bird",
68
+ port:
69
+ parseInt(process.env.DB_PORT) ||
70
+ (this.type === "postgres" ? 5432 : 3306),
71
+ charset: "utf8mb4",
72
+ waitForConnections: true,
73
+ connectionLimit: connectionLimit,
74
+ queueLimit: queueLimit,
75
+ max: connectionLimit,
76
+ };
77
+
78
+ if (process.env.DATABASE_URL && !process.env.DATABASE_URL.startsWith("#")) {
79
+ try {
80
+ if (
81
+ process.env.DATABASE_URL.startsWith("mysql://") ||
82
+ process.env.DATABASE_URL.startsWith("postgres://") ||
83
+ process.env.DATABASE_URL.startsWith("postgresql://")
84
+ ) {
85
+ const url = new URL(process.env.DATABASE_URL);
86
+ this.config.host = url.hostname;
87
+ this.config.port =
88
+ parseInt(url.port) || (this.type === "postgres" ? 5432 : 3306);
89
+ this.config.user = url.username;
90
+ this.config.password = url.password;
91
+ this.config.database = url.pathname.substring(1);
92
+ this.config.connectionString = process.env.DATABASE_URL;
93
+ }
94
+ } catch (err) {
95
+ console.error(
96
+ "[DATABASE ERROR] Failed to parse DATABASE_URL:",
97
+ err.message,
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Creates the database connection pool with 3 retry attempts on failure.
105
+ * @param {number} [retries=3] - Number of connection attempts.
106
+ * @returns {Promise<boolean>} True if connection pool was created.
107
+ */
108
+ async init(retries = 3) {
109
+ if (!mysqlPromise && !pgPromise) return false;
110
+ if (this.pool) return true;
111
+
112
+ for (let attempt = 1; attempt <= retries; attempt++) {
113
+ try {
114
+ if (this.type === "postgres" && pgPromise) {
115
+ const pg = pgPromise.default || pgPromise;
116
+ this.pool = new pg.Pool(this.config);
117
+ await this.pool.query("SELECT 1");
118
+ } else if (mysqlPromise) {
119
+ const { max, connectionString, ...mysqlConfig } = this.config;
120
+ this.pool = mysqlPromise.createPool(mysqlConfig);
121
+ await this.pool.query("SELECT 1");
122
+ }
123
+ return true;
124
+ } catch (err) {
125
+ this.pool = null;
126
+ if (attempt === retries) {
127
+ console.error(
128
+ `[DATABASE ERROR] Connection failed after ${retries} attempts:`,
129
+ err.message,
130
+ );
131
+ return false;
132
+ }
133
+ await new Promise((resolve) => setTimeout(resolve, 1000));
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Runs a SQL query with parameters and formatting options.
141
+ * Supports both MySQL and PostgreSQL (converting ? to $1, $2 for Postgres automatically).
142
+ *
143
+ * @param {string} sql - SQL query string.
144
+ * @param {Array} [params=[]] - Query parameter array.
145
+ * @param {Object|string} [options={}] - Query options. Supports 'return_row', 'return_rows', and 'cache' (seconds).
146
+ * @returns {Promise<*>| int | boolean} Formatted query result or false on error, or insert id of insert query.
147
+ * @example const result = await connection.query("SELECT * FROM users WHERE id = ?", [1], "return_row");
148
+ * @example const result = await connection.query("SELECT * FROM users WHERE id = ?", [1], { cache: 60 });
149
+ * @example const result = await connection.query("SELECT * FROM users WHERE id = ?", [1], { debug: true });
150
+ * @example const insert_id = await connection.query("INSERT INTO users (name, email, password) VALUES (?, ?, ?)", ["John Doe", "john@example.com", "password"]);
151
+ */
152
+ async query(sql, params = [], options = {}) {
153
+ if (!mysqlPromise && !pgPromise) return false;
154
+ if (!this.pool) {
155
+ const initialized = await this.init();
156
+ if (!initialized) return false;
157
+ }
158
+
159
+ const queryOptions =
160
+ typeof options === "string" ? { [options]: true } : options;
161
+ const cleanSql = sql.trim();
162
+ const isSelect = cleanSql.toLowerCase().startsWith("select");
163
+ const isInsert = cleanSql.toLowerCase().startsWith("insert");
164
+
165
+ const redisClient = getRedisClient();
166
+ let cacheKey = null;
167
+ const isDebug = queryOptions.debug ?? false;
168
+ if (isDebug) {
169
+ console.log("[DATABASE DEBUG] SQL:", sql);
170
+ console.log("[DATABASE DEBUG] PARAMS:", params);
171
+ console.log("[DATABASE DEBUG] OPTIONS:", options);
172
+ }
173
+
174
+ if (isSelect && queryOptions.cache && redisClient) {
175
+ const hash = crypto
176
+ .createHash("md5")
177
+ .update(cleanSql + JSON.stringify(params))
178
+ .digest("hex");
179
+ cacheKey = `db:${hash}`;
180
+ try {
181
+ if (isDebug) {
182
+ console.log("[DATABASE DEBUG][Redis] CACHE KEY:", cacheKey);
183
+ }
184
+ const cached = await redisClient.get(cacheKey);
185
+ if (cached) {
186
+ if (isDebug) {
187
+ console.log("[DATABASE DEBUG][Redis] CACHE HIT");
188
+ }
189
+ return JSON.parse(cached);
190
+ } else {
191
+ if (isDebug) {
192
+ console.log("[DATABASE DEBUG][Redis] CACHE MISS");
193
+ }
194
+ }
195
+ } catch (err) {
196
+ console.error(
197
+ "[DATABASE ERROR] Failed to get cached data:",
198
+ err.message,
199
+ );
200
+ }
201
+ }
202
+
203
+ try {
204
+ if (this.type === "postgres" && pgPromise) {
205
+ let paramIndex = 1;
206
+ const pgSql = cleanSql.replace(/\?/g, () => `$${paramIndex++}`);
207
+ const res = await this.pool.query(pgSql, params);
208
+
209
+ if (isSelect) {
210
+ const rows = res.rows || [];
211
+ if (cacheKey && queryOptions.cache && redisClient) {
212
+ await redisClient
213
+ .set(cacheKey, JSON.stringify(rows), {
214
+ EX: parseInt(queryOptions.cache),
215
+ })
216
+ .catch(() => {});
217
+ }
218
+ if (queryOptions.return_row) {
219
+ return rows.length > 0 ? rows[0] : null;
220
+ }
221
+ return rows;
222
+ }
223
+
224
+ if (isInsert) {
225
+ if (res.rows && res.rows.length > 0) {
226
+ return res.rows[0].id || res.rows[0];
227
+ }
228
+ return res.rowCount;
229
+ }
230
+
231
+ return res.rowCount;
232
+ } else {
233
+ const [results] = await this.pool.execute(cleanSql, params);
234
+
235
+ if (isSelect) {
236
+ const rows = Array.isArray(results) ? results : [];
237
+ if (cacheKey && queryOptions.cache && redisClient) {
238
+ await redisClient
239
+ .set(cacheKey, JSON.stringify(rows), {
240
+ EX: parseInt(queryOptions.cache),
241
+ })
242
+ .catch(() => {});
243
+ }
244
+ if (queryOptions.return_row) {
245
+ return rows.length > 0 ? rows[0] : null;
246
+ }
247
+ return rows;
248
+ }
249
+
250
+ if (isInsert) {
251
+ return results.insertId || results;
252
+ }
253
+
254
+ return results;
255
+ }
256
+ } catch (err) {
257
+ console.error("[DATABASE ERROR] Query execution failed:", err.message);
258
+ throw err;
259
+ }
260
+ }
261
+ }
262
+
263
+ export { Database, DB_TYPE };