chadstart 1.0.0 → 1.0.2

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.
package/core/db.js CHANGED
@@ -2,15 +2,19 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
- const Database = require('better-sqlite3');
6
5
  const path = require('path');
7
6
  const logger = require('../utils/logger');
8
7
 
9
- let db = null;
10
- // Cached entity metadata for relation queries. Set by initDb.
8
+ const DB_ENGINE = (process.env.DB_ENGINE || 'sqlite').toLowerCase();
9
+
10
+ let _sqliteDb = null;
11
+ let _pgPool = null;
12
+ let _mysqlPool = null;
11
13
  let _core = null;
12
14
 
13
- const SQL_TYPE = {
15
+ // ─── SQL type maps ────────────────────────────────────────────────────────────
16
+
17
+ const SQL_TYPE_SQLITE = {
14
18
  text: 'TEXT', string: 'TEXT', richText: 'TEXT',
15
19
  integer: 'INTEGER', int: 'INTEGER',
16
20
  number: 'REAL', float: 'REAL', real: 'REAL', money: 'REAL',
@@ -20,98 +24,222 @@ const SQL_TYPE = {
20
24
  file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
21
25
  };
22
26
 
27
+ const SQL_TYPE_PG = {
28
+ text: 'TEXT', string: 'TEXT', richText: 'TEXT',
29
+ integer: 'INTEGER', int: 'INTEGER',
30
+ number: 'NUMERIC', float: 'NUMERIC', real: 'NUMERIC', money: 'NUMERIC',
31
+ boolean: 'BOOLEAN', bool: 'BOOLEAN',
32
+ date: 'TEXT', timestamp: 'TEXT', email: 'TEXT', link: 'TEXT',
33
+ password: 'TEXT', choice: 'TEXT', location: 'TEXT',
34
+ file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
35
+ };
36
+
37
+ const SQL_TYPE_MYSQL = {
38
+ text: 'TEXT', string: 'TEXT', richText: 'TEXT',
39
+ integer: 'INT', int: 'INT',
40
+ number: 'DECIMAL(15,4)', float: 'DECIMAL(15,4)', real: 'DECIMAL(15,4)', money: 'DECIMAL(15,4)',
41
+ boolean: 'TINYINT(1)', bool: 'TINYINT(1)',
42
+ date: 'TEXT', timestamp: 'TEXT', email: 'TEXT', link: 'TEXT',
43
+ password: 'TEXT', choice: 'TEXT', location: 'TEXT',
44
+ file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
45
+ };
46
+
47
+ function sqlType(type) {
48
+ if (DB_ENGINE === 'postgres') return SQL_TYPE_PG[type] || 'TEXT';
49
+ if (DB_ENGINE === 'mysql') return SQL_TYPE_MYSQL[type] || 'TEXT';
50
+ return SQL_TYPE_SQLITE[type] || 'TEXT';
51
+ }
52
+
53
+ // ID column type — MySQL needs VARCHAR(36) because TEXT can't be primary key
54
+ function idColType() {
55
+ return DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
56
+ }
57
+
58
+ // Auth string column type — MySQL requires bounded VARCHAR for UNIQUE-indexed columns
59
+ function authStrType() {
60
+ return DB_ENGINE === 'mysql' ? 'VARCHAR(191)' : 'TEXT';
61
+ }
62
+
23
63
  function generateUUID() {
24
64
  return crypto.randomUUID();
25
65
  }
26
66
 
27
- function initDb(core, dbPath) {
28
- const resolved = dbPath ? path.resolve(dbPath) : path.resolve(process.env.DB_PATH || 'data/chadstart.db');
67
+ // Quote an identifier for the current database engine
68
+ function q(name) {
69
+ if (DB_ENGINE === 'mysql') return `\`${name}\``;
70
+ return `"${name}"`;
71
+ }
72
+
73
+ // Convert ? placeholders to $1, $2, ... for PostgreSQL
74
+ function toPgPlaceholders(sql) {
75
+ let n = 0;
76
+ return sql.replace(/\?/g, () => `$${++n}`);
77
+ }
78
+
79
+ // ─── Low-level async query helpers ───────────────────────────────────────────
80
+
81
+ async function exec(sql) {
82
+ if (DB_ENGINE === 'postgres') { await _pgPool.query(sql); return; }
83
+ if (DB_ENGINE === 'mysql') { await _mysqlPool.query(sql); return; }
84
+ _sqliteDb.exec(sql);
85
+ }
86
+
87
+ async function queryAll(sql, params = []) {
88
+ if (DB_ENGINE === 'postgres') {
89
+ const result = await _pgPool.query(toPgPlaceholders(sql), params);
90
+ return result.rows;
91
+ }
92
+ if (DB_ENGINE === 'mysql') {
93
+ const [rows] = await _mysqlPool.query(sql, params);
94
+ return rows;
95
+ }
96
+ return _sqliteDb.prepare(sql).all(...params);
97
+ }
98
+
99
+ async function queryOne(sql, params = []) {
100
+ const rows = await queryAll(sql, params);
101
+ return rows[0] || null;
102
+ }
103
+
104
+ async function queryRun(sql, params = []) {
105
+ if (DB_ENGINE === 'postgres') {
106
+ await _pgPool.query(toPgPlaceholders(sql), params);
107
+ return;
108
+ }
109
+ if (DB_ENGINE === 'mysql') {
110
+ await _mysqlPool.query(sql, params);
111
+ return;
112
+ }
113
+ _sqliteDb.prepare(sql).run(...params);
114
+ }
115
+
116
+ // Build an INSERT OR IGNORE / INSERT IGNORE / INSERT...ON CONFLICT DO NOTHING
117
+ // statement appropriate for the current engine.
118
+ function buildInsertOrIgnoreSql(table, colA, colB) {
119
+ if (DB_ENGINE === 'postgres') {
120
+ return `INSERT INTO ${q(table)} (${q(colA)}, ${q(colB)}) VALUES (?, ?) ON CONFLICT DO NOTHING`;
121
+ }
122
+ if (DB_ENGINE === 'mysql') {
123
+ return `INSERT IGNORE INTO ${q(table)} (${q(colA)}, ${q(colB)}) VALUES (?, ?)`;
124
+ }
125
+ return `INSERT OR IGNORE INTO ${q(table)} (${q(colA)}, ${q(colB)}) VALUES (?, ?)`;
126
+ }
127
+
128
+ // ─── Initialization ───────────────────────────────────────────────────────────
129
+
130
+ async function initDb(core, dbPath) {
131
+ _core = core;
132
+
133
+ if (DB_ENGINE === 'postgres') {
134
+ const { Pool } = require('pg');
135
+ _pgPool = new Pool({
136
+ host: process.env.DB_HOST || 'localhost',
137
+ port: parseInt(process.env.DB_PORT || '5432', 10),
138
+ user: process.env.DB_USERNAME || 'postgres',
139
+ password: process.env.DB_PASSWORD || 'postgres',
140
+ database: process.env.DB_DATABASE || 'manifest',
141
+ ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
142
+ });
143
+ await _pgPool.query('SELECT 1');
144
+ logger.info('PostgreSQL database connected');
145
+ await syncSchema(core);
146
+ return _pgPool;
147
+ }
148
+
149
+ if (DB_ENGINE === 'mysql') {
150
+ const mysql = require('mysql2/promise');
151
+ _mysqlPool = await mysql.createPool({
152
+ host: process.env.DB_HOST || 'localhost',
153
+ port: parseInt(process.env.DB_PORT || '3306', 10),
154
+ user: process.env.DB_USERNAME || 'root',
155
+ password: process.env.DB_PASSWORD || '',
156
+ database: process.env.DB_DATABASE || 'manifest',
157
+ ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : undefined,
158
+ waitForConnections: true,
159
+ connectionLimit: 10,
160
+ });
161
+ await _mysqlPool.query('SELECT 1');
162
+ logger.info('MySQL database connected');
163
+ await syncSchema(core);
164
+ return _mysqlPool;
165
+ }
166
+
167
+ // SQLite (default)
168
+ const Database = require('better-sqlite3');
169
+ const resolved = dbPath
170
+ ? path.resolve(dbPath)
171
+ : path.resolve(process.env.DB_PATH || '/data/chadstart.db');
29
172
  try {
30
173
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
31
174
  } catch (err) {
32
175
  throw new Error(`Failed to create database directory "${path.dirname(resolved)}": ${err.message}`);
33
176
  }
34
177
  try {
35
- db = new Database(resolved);
178
+ _sqliteDb = new Database(resolved);
36
179
  } catch (err) {
37
180
  throw new Error(
38
181
  `Failed to open database at "${resolved}": ${err.message}\n` +
39
182
  ` Make sure the directory exists and is writable, and that no other process has an exclusive lock on the file.`
40
183
  );
41
184
  }
42
- db.pragma('journal_mode = WAL');
43
- db.pragma('foreign_keys = ON');
44
- _core = core;
45
- logger.info(`Database initialized at ${resolved}`);
46
- syncSchema(core);
47
- return db;
185
+ _sqliteDb.pragma('journal_mode = WAL');
186
+ _sqliteDb.pragma('foreign_keys = ON');
187
+ logger.info(`SQLite database initialized at ${resolved}`);
188
+ await syncSchema(core);
189
+ return _sqliteDb;
48
190
  }
49
191
 
50
- function syncSchema(core) {
51
- for (const entity of Object.values(core.entities)) {
52
- const cols = buildColumnDefs(entity, core.entities);
53
- const existing = getExistingColumns(entity.tableName);
192
+ /** Return the raw SQLite connection (only valid in sqlite mode). */
193
+ function getDb() {
194
+ if (DB_ENGINE !== 'sqlite') throw new Error('getDb() is only available in SQLite mode');
195
+ if (!_sqliteDb) throw new Error('Database not initialized. Call initDb() first.');
196
+ return _sqliteDb;
197
+ }
54
198
 
55
- if (!existing) {
56
- const defs = ['"id" TEXT PRIMARY KEY', '"createdAt" TEXT', '"updatedAt" TEXT', ...cols.map((c) => c.def)];
57
- db.exec(`CREATE TABLE "${entity.tableName}" (${defs.join(', ')})`);
58
- } else {
59
- // Add createdAt/updatedAt if missing (migration)
60
- if (!existing.has('createdAt')) {
61
- db.exec(`ALTER TABLE "${entity.tableName}" ADD COLUMN "createdAt" TEXT`);
62
- }
63
- if (!existing.has('updatedAt')) {
64
- db.exec(`ALTER TABLE "${entity.tableName}" ADD COLUMN "updatedAt" TEXT`);
65
- }
66
- for (const col of cols) {
67
- if (!existing.has(col.name)) {
68
- db.exec(`ALTER TABLE "${entity.tableName}" ADD COLUMN ${stripConstraints(col.def)}`);
69
- }
70
- }
71
- }
72
- }
199
+ // ─── Schema helpers ───────────────────────────────────────────────────────────
73
200
 
74
- // belongsToMany junction tables
75
- for (const entity of Object.values(core.entities)) {
76
- for (const rel of entity.belongsToMany || []) {
77
- const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
78
- const relEntity = core.entities[relName];
79
- if (!relEntity) continue;
80
- const [a, b] = [entity.tableName, relEntity.tableName].sort();
81
- const jt = `${a}_${b}`;
82
- if (!getExistingColumns(jt)) {
83
- db.exec(
84
- `CREATE TABLE "${jt}" ("${a}_id" TEXT REFERENCES "${a}"(id), "${b}_id" TEXT REFERENCES "${b}"(id), PRIMARY KEY ("${a}_id", "${b}_id"))`
85
- );
86
- }
87
- }
201
+ async function getExistingColumns(tableName) {
202
+ if (DB_ENGINE === 'postgres') {
203
+ const rows = await queryAll(
204
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?`,
205
+ [tableName]
206
+ );
207
+ return rows.length ? new Set(rows.map((r) => r.column_name)) : null;
88
208
  }
89
- }
90
-
91
- function getExistingColumns(table) {
209
+ if (DB_ENGINE === 'mysql') {
210
+ const rows = await queryAll(
211
+ `SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?`,
212
+ [tableName]
213
+ );
214
+ return rows.length ? new Set(rows.map((r) => r.COLUMN_NAME || r.column_name)) : null;
215
+ }
216
+ // SQLite
92
217
  try {
93
- const rows = db.pragma(`table_info("${table}")`);
218
+ const rows = _sqliteDb.pragma(`table_info("${tableName}")`);
94
219
  return rows && rows.length ? new Set(rows.map((r) => r.name)) : null;
95
220
  } catch { return null; }
96
221
  }
97
222
 
98
223
  function stripConstraints(def) {
99
- return def.replace(/\bNOT\s+NULL\b/gi, '').replace(/\bUNIQUE\b/gi, '')
100
- .replace(/\bREFERENCES\s+"[^"]+"\([^)]+\)/gi, '').replace(/\s{2,}/g, ' ').trim();
224
+ return def
225
+ .replace(/\bNOT\s+NULL\b/gi, '')
226
+ .replace(/\bUNIQUE\b/gi, '')
227
+ .replace(/\bREFERENCES\s+[`"]?[^`"\s(]+[`"]?\s*\([^)]+\)/gi, '')
228
+ .replace(/\s{2,}/g, ' ')
229
+ .trim();
101
230
  }
102
231
 
103
232
  function buildColumnDefs(entity, allEntities) {
104
233
  const cols = [];
105
234
 
106
235
  if (entity.authenticable) {
107
- cols.push({ name: 'email', def: '"email" TEXT NOT NULL UNIQUE' });
108
- cols.push({ name: 'password', def: '"password" TEXT NOT NULL' });
236
+ cols.push({ name: 'email', def: `${q('email')} ${authStrType()} NOT NULL UNIQUE` });
237
+ cols.push({ name: 'password', def: `${q('password')} ${authStrType()} NOT NULL` });
109
238
  }
110
239
 
111
240
  for (const p of entity.properties) {
112
- // Skip email/password for authenticable entities — they are already added above
113
241
  if (entity.authenticable && (p.name === 'email' || p.name === 'password')) continue;
114
- cols.push({ name: p.name, def: `"${p.name}" ${SQL_TYPE[p.type] || 'TEXT'}` });
242
+ cols.push({ name: p.name, def: `${q(p.name)} ${sqlType(p.type)}` });
115
243
  }
116
244
 
117
245
  for (const rel of entity.belongsTo || []) {
@@ -119,38 +247,76 @@ function buildColumnDefs(entity, allEntities) {
119
247
  const ref = allEntities[relName];
120
248
  if (ref) {
121
249
  const fk = `${ref.tableName}_id`;
122
- cols.push({ name: fk, def: `"${fk}" TEXT REFERENCES "${ref.tableName}"(id)` });
250
+ cols.push({ name: fk, def: `${q(fk)} ${idColType()} REFERENCES ${q(ref.tableName)}(id)` });
123
251
  }
124
252
  }
125
253
 
126
254
  return cols;
127
255
  }
128
256
 
129
- function getDb() {
130
- if (!db) throw new Error('Database not initialized. Call initDb() first.');
131
- return db;
257
+ async function syncSchema(core) {
258
+ for (const entity of Object.values(core.entities)) {
259
+ const cols = buildColumnDefs(entity, core.entities);
260
+ const existing = await getExistingColumns(entity.tableName);
261
+
262
+ if (!existing) {
263
+ const defs = [
264
+ `${q('id')} ${idColType()} PRIMARY KEY`,
265
+ `${q('createdAt')} TEXT`,
266
+ `${q('updatedAt')} TEXT`,
267
+ ...cols.map((c) => c.def),
268
+ ];
269
+ await exec(`CREATE TABLE IF NOT EXISTS ${q(entity.tableName)} (${defs.join(', ')})`);
270
+ } else {
271
+ if (!existing.has('createdAt')) {
272
+ await exec(`ALTER TABLE ${q(entity.tableName)} ADD COLUMN ${q('createdAt')} TEXT`);
273
+ }
274
+ if (!existing.has('updatedAt')) {
275
+ await exec(`ALTER TABLE ${q(entity.tableName)} ADD COLUMN ${q('updatedAt')} TEXT`);
276
+ }
277
+ for (const col of cols) {
278
+ if (!existing.has(col.name)) {
279
+ await exec(`ALTER TABLE ${q(entity.tableName)} ADD COLUMN ${stripConstraints(col.def)}`);
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // belongsToMany junction tables
286
+ for (const entity of Object.values(core.entities)) {
287
+ for (const rel of entity.belongsToMany || []) {
288
+ const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
289
+ const relEntity = core.entities[relName];
290
+ if (!relEntity) continue;
291
+ const [a, b] = [entity.tableName, relEntity.tableName].sort();
292
+ const jt = `${a}_${b}`;
293
+ if (!await getExistingColumns(jt)) {
294
+ const aCol = `${q(`${a}_id`)} ${idColType()} REFERENCES ${q(a)}(id)`;
295
+ const bCol = `${q(`${b}_id`)} ${idColType()} REFERENCES ${q(b)}(id)`;
296
+ await exec(
297
+ `CREATE TABLE IF NOT EXISTS ${q(jt)} (${aCol}, ${bCol}, PRIMARY KEY (${q(`${a}_id`)}, ${q(`${b}_id`)}))`
298
+ );
299
+ }
300
+ }
301
+ }
132
302
  }
133
303
 
134
- // ─── Filter parsing ──────────────────────────────────────────────────────────
304
+ // ─── Filter parsing ───────────────────────────────────────────────────────────
135
305
 
136
306
  const FILTER_SUFFIXES = {
137
- _eq: (col, val) => ({ sql: `"${col}" = ?`, val }),
138
- _neq: (col, val) => ({ sql: `"${col}" != ?`, val }),
139
- _gt: (col, val) => ({ sql: `"${col}" > ?`, val }),
140
- _gte: (col, val) => ({ sql: `"${col}" >= ?`, val }),
141
- _lt: (col, val) => ({ sql: `"${col}" < ?`, val }),
142
- _lte: (col, val) => ({ sql: `"${col}" <= ?`, val }),
143
- _like: (col, val) => ({ sql: `"${col}" LIKE ?`, val }),
307
+ _eq: (col, val) => ({ sql: `${q(col)} = ?`, val }),
308
+ _neq: (col, val) => ({ sql: `${q(col)} != ?`, val }),
309
+ _gt: (col, val) => ({ sql: `${q(col)} > ?`, val }),
310
+ _gte: (col, val) => ({ sql: `${q(col)} >= ?`, val }),
311
+ _lt: (col, val) => ({ sql: `${q(col)} < ?`, val }),
312
+ _lte: (col, val) => ({ sql: `${q(col)} <= ?`, val }),
313
+ _like: (col, val) => ({ sql: `${q(col)} LIKE ?`, val }),
144
314
  _in: (col, val) => {
145
315
  const items = String(val).split(',');
146
- return { sql: `"${col}" IN (${items.map(() => '?').join(',')})`, val: items };
316
+ return { sql: `${q(col)} IN (${items.map(() => '?').join(',')})`, val: items };
147
317
  },
148
318
  };
149
319
 
150
- /**
151
- * Parse query string params into filter clauses.
152
- * Supports: prop=val (exact match), prop_eq=val, prop_gt=val, etc.
153
- */
154
320
  function parseFilters(query, validColumns) {
155
321
  const clauses = [];
156
322
  const values = [];
@@ -174,9 +340,8 @@ function parseFilters(query, validColumns) {
174
340
  }
175
341
  }
176
342
 
177
- // Exact match (no suffix)
178
343
  if (!matched && validColumns.has(key)) {
179
- clauses.push(`"${key}" = ?`);
344
+ clauses.push(`${q(key)} = ?`);
180
345
  values.push(val);
181
346
  }
182
347
  }
@@ -184,31 +349,47 @@ function parseFilters(query, validColumns) {
184
349
  return { clauses, values };
185
350
  }
186
351
 
187
- // ─── CRUD ────────────────────────────────────────────────────────────────────
352
+ // ─── Column introspection (for CRUD query safety) ─────────────────────────────
353
+
354
+ async function getValidColumns(table) {
355
+ if (DB_ENGINE === 'postgres') {
356
+ const rows = await queryAll(
357
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?`,
358
+ [table]
359
+ );
360
+ return new Set(rows.map((r) => r.column_name));
361
+ }
362
+ if (DB_ENGINE === 'mysql') {
363
+ const rows = await queryAll(
364
+ `SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?`,
365
+ [table]
366
+ );
367
+ return new Set(rows.map((r) => r.COLUMN_NAME || r.column_name));
368
+ }
369
+ return new Set(_sqliteDb.pragma(`table_info("${table}")`).map((r) => r.name));
370
+ }
371
+
372
+ // ─── CRUD ─────────────────────────────────────────────────────────────────────
188
373
 
189
- /**
190
- * Query rows with filter suffixes, ordering, and pagination.
191
- * opts: { page, perPage, orderBy, order, relations }
192
- */
193
- function findAll(table, query = {}, opts = {}) {
194
- const d = getDb();
195
- const validCols = new Set(d.pragma(`table_info("${table}")`).map((r) => r.name));
374
+ async function findAll(table, query = {}, opts = {}) {
375
+ const validCols = await getValidColumns(table);
196
376
  const { clauses, values } = parseFilters(query, validCols);
197
377
 
198
- let sql = `SELECT * FROM "${table}"`;
378
+ let sql = `SELECT * FROM ${q(table)}`;
199
379
  if (clauses.length) sql += ` WHERE ${clauses.join(' AND ')}`;
200
380
 
201
- // Orderingonly allow column names that exist and match safe pattern
381
+ // Count total build before adding ORDER BY (PostgreSQL disallows ORDER BY in aggregate queries)
382
+ const countSql = sql.replace(/^SELECT \*/, 'SELECT COUNT(*) as total');
383
+ const countRow = await queryOne(countSql, values);
384
+
385
+ // Ordering (added after count so the count query stays clean)
202
386
  const SAFE_COL = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
203
387
  const orderBy = opts.orderBy || 'createdAt';
204
388
  const orderDir = (opts.order || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
205
389
  if (validCols.has(orderBy) && SAFE_COL.test(orderBy)) {
206
- sql += ` ORDER BY "${orderBy}" ${orderDir}`;
390
+ sql += ` ORDER BY ${q(orderBy)} ${orderDir}`;
207
391
  }
208
-
209
- // Count total before pagination
210
- const countSql = sql.replace(/^SELECT \*/, 'SELECT COUNT(*) as total');
211
- const total = d.prepare(countSql).get(...values).total;
392
+ const total = Number(countRow.total);
212
393
 
213
394
  // Pagination
214
395
  const page = Math.max(1, parseInt(opts.page, 10) || 1);
@@ -216,7 +397,7 @@ function findAll(table, query = {}, opts = {}) {
216
397
  const offset = (page - 1) * perPage;
217
398
  sql += ` LIMIT ? OFFSET ?`;
218
399
 
219
- const data = d.prepare(sql).all(...values, perPage, offset);
400
+ const data = await queryAll(sql, [...values, perPage, offset]);
220
401
  const lastPage = Math.max(1, Math.ceil(total / perPage));
221
402
 
222
403
  return {
@@ -230,65 +411,58 @@ function findAll(table, query = {}, opts = {}) {
230
411
  };
231
412
  }
232
413
 
233
- /**
234
- * Simple findAll without pagination for internal use (e.g., auth lookups).
235
- */
236
- function findAllSimple(table, filters = {}) {
237
- const d = getDb();
414
+ async function findAllSimple(table, filters = {}) {
238
415
  const keys = Object.keys(filters);
239
- if (!keys.length) return d.prepare(`SELECT * FROM "${table}"`).all();
240
- const valid = new Set(d.pragma(`table_info("${table}")`).map((r) => r.name));
241
- const safe = Object.fromEntries(keys.filter((k) => valid.has(k)).map((k) => [k, filters[k]]));
242
- if (!Object.keys(safe).length) return d.prepare(`SELECT * FROM "${table}"`).all();
243
- const where = Object.keys(safe).map((k) => `"${k}" = ?`).join(' AND ');
244
- return d.prepare(`SELECT * FROM "${table}" WHERE ${where}`).all(...Object.values(safe));
416
+ if (!keys.length) return queryAll(`SELECT * FROM ${q(table)}`, []);
417
+ const validCols = await getValidColumns(table);
418
+ const safe = Object.fromEntries(keys.filter((k) => validCols.has(k)).map((k) => [k, filters[k]]));
419
+ if (!Object.keys(safe).length) return queryAll(`SELECT * FROM ${q(table)}`, []);
420
+ const where = Object.keys(safe).map((k) => `${q(k)} = ?`).join(' AND ');
421
+ return queryAll(`SELECT * FROM ${q(table)} WHERE ${where}`, Object.values(safe));
245
422
  }
246
423
 
247
- function findById(table, id) {
248
- return getDb().prepare(`SELECT * FROM "${table}" WHERE id = ?`).get(id) || null;
424
+ async function findById(table, id) {
425
+ return queryOne(`SELECT * FROM ${q(table)} WHERE ${q('id')} = ?`, [id]);
249
426
  }
250
427
 
251
- function create(table, data) {
252
- const d = getDb();
428
+ async function create(table, data) {
253
429
  const now = new Date().toISOString();
254
430
  const id = generateUUID();
255
431
  const full = { id, createdAt: now, updatedAt: now, ...data };
256
432
  const keys = Object.keys(full);
257
- const cols = keys.map((k) => `"${k}"`).join(', ');
433
+ const cols = keys.map((k) => q(k)).join(', ');
258
434
  const ph = keys.map(() => '?').join(', ');
259
- d.prepare(`INSERT INTO "${table}" (${cols}) VALUES (${ph})`).run(...Object.values(full));
435
+ await queryRun(`INSERT INTO ${q(table)} (${cols}) VALUES (${ph})`, Object.values(full));
260
436
  return findById(table, id);
261
437
  }
262
438
 
263
- function update(table, id, data) {
439
+ async function update(table, id, data) {
264
440
  const now = new Date().toISOString();
265
441
  const full = { ...data, updatedAt: now };
266
442
  const keys = Object.keys(full);
267
443
  if (!keys.length) return findById(table, id);
268
- const set = keys.map((k) => `"${k}" = ?`).join(', ');
269
- getDb().prepare(`UPDATE "${table}" SET ${set} WHERE id = ?`).run(...Object.values(full), id);
444
+ const set = keys.map((k) => `${q(k)} = ?`).join(', ');
445
+ await queryRun(`UPDATE ${q(table)} SET ${set} WHERE ${q('id')} = ?`, [...Object.values(full), id]);
270
446
  return findById(table, id);
271
447
  }
272
448
 
273
- function remove(table, id) {
274
- const existing = findById(table, id);
449
+ async function remove(table, id) {
450
+ const existing = await findById(table, id);
275
451
  if (!existing) return null;
276
- getDb().prepare(`DELETE FROM "${table}" WHERE id = ?`).run(id);
452
+ await queryRun(`DELETE FROM ${q(table)} WHERE ${q('id')} = ?`, [id]);
277
453
  return existing;
278
454
  }
279
455
 
280
- // ─── Relation helpers ────────────────────────────────────────────────────────
456
+ // ─── Relation helpers ─────────────────────────────────────────────────────────
281
457
 
282
- /**
283
- * Load relations for a single row. Mutates the row in-place.
284
- * relationNames: comma-separated string or array.
285
- */
286
- function loadRelations(row, entity, relationNames) {
458
+ async function loadRelations(row, entity, relationNames) {
287
459
  if (!row || !entity || !relationNames || !_core) return row;
288
- const names = Array.isArray(relationNames) ? relationNames : relationNames.split(',').map((s) => s.trim());
460
+ const names = Array.isArray(relationNames)
461
+ ? relationNames
462
+ : relationNames.split(',').map((s) => s.trim());
289
463
 
290
464
  for (const relName of names) {
291
- // belongsTo: look up the FK column
465
+ // belongsTo
292
466
  const btRel = (entity.belongsTo || []).find((r) => {
293
467
  const rName = typeof r === 'string' ? r : (r.name || r.entity);
294
468
  return rName.toLowerCase() === relName.toLowerCase();
@@ -298,16 +472,12 @@ function loadRelations(row, entity, relationNames) {
298
472
  const relEntity = _core.entities[relEntityName];
299
473
  if (relEntity) {
300
474
  const fk = `${relEntity.tableName}_id`;
301
- if (row[fk]) {
302
- row[relName] = findById(relEntity.tableName, row[fk]);
303
- } else {
304
- row[relName] = null;
305
- }
475
+ row[relName] = row[fk] ? await findById(relEntity.tableName, row[fk]) : null;
306
476
  }
307
477
  continue;
308
478
  }
309
479
 
310
- // belongsToMany: look up junction table
480
+ // belongsToMany
311
481
  const btmRel = (entity.belongsToMany || []).find((r) => {
312
482
  const rName = typeof r === 'string' ? r : (r.name || r.entity);
313
483
  return rName.toLowerCase() === relName.toLowerCase();
@@ -320,15 +490,15 @@ function loadRelations(row, entity, relationNames) {
320
490
  const jt = `${a}_${b}`;
321
491
  const myCol = `${entity.tableName}_id`;
322
492
  const otherCol = `${relEntity.tableName}_id`;
323
- const related = getDb()
324
- .prepare(`SELECT t.* FROM "${relEntity.tableName}" t JOIN "${jt}" j ON j."${otherCol}" = t.id WHERE j."${myCol}" = ?`)
325
- .all(row.id);
326
- row[relName] = related;
493
+ row[relName] = await queryAll(
494
+ `SELECT t.* FROM ${q(relEntity.tableName)} t JOIN ${q(jt)} j ON j.${q(otherCol)} = t.id WHERE j.${q(myCol)} = ?`,
495
+ [row.id]
496
+ );
327
497
  }
328
498
  continue;
329
499
  }
330
500
 
331
- // hasMany (reverse belongsTo): another entity belongsTo this entity
501
+ // hasMany (reverse belongsTo)
332
502
  for (const otherEntity of Object.values(_core.entities)) {
333
503
  const reverseRel = (otherEntity.belongsTo || []).find((r) => {
334
504
  const rEntity = typeof r === 'string' ? r : (r.entity || r.name);
@@ -336,9 +506,10 @@ function loadRelations(row, entity, relationNames) {
336
506
  });
337
507
  if (reverseRel && otherEntity.slug.toLowerCase() === relName.toLowerCase()) {
338
508
  const fk = `${entity.tableName}_id`;
339
- row[relName] = getDb()
340
- .prepare(`SELECT * FROM "${otherEntity.tableName}" WHERE "${fk}" = ?`)
341
- .all(row.id);
509
+ row[relName] = await queryAll(
510
+ `SELECT * FROM ${q(otherEntity.tableName)} WHERE ${q(fk)} = ?`,
511
+ [row.id]
512
+ );
342
513
  break;
343
514
  }
344
515
  }
@@ -347,18 +518,13 @@ function loadRelations(row, entity, relationNames) {
347
518
  return row;
348
519
  }
349
520
 
350
- /**
351
- * Store belongsToMany relations for a record.
352
- * body may contain keys like `skillIds: [id1, id2]`.
353
- */
354
- function saveBelongsToMany(entity, recordId, body) {
521
+ async function saveBelongsToMany(entity, recordId, body) {
355
522
  if (!_core) return;
356
523
  for (const rel of entity.belongsToMany || []) {
357
524
  const relEntityName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
358
525
  const relEntity = _core.entities[relEntityName];
359
526
  if (!relEntity) continue;
360
527
 
361
- // Convention: entityIds (camelCase plural)
362
528
  const idsKey = `${relEntityName.charAt(0).toLowerCase() + relEntityName.slice(1)}Ids`;
363
529
  const ids = body[idsKey];
364
530
  if (!Array.isArray(ids)) continue;
@@ -369,16 +535,25 @@ function saveBelongsToMany(entity, recordId, body) {
369
535
  const otherCol = `${relEntity.tableName}_id`;
370
536
 
371
537
  // Clear existing
372
- getDb().prepare(`DELETE FROM "${jt}" WHERE "${myCol}" = ?`).run(recordId);
538
+ await queryRun(`DELETE FROM ${q(jt)} WHERE ${q(myCol)} = ?`, [recordId]);
373
539
 
374
540
  // Insert new
375
- const ins = getDb().prepare(`INSERT OR IGNORE INTO "${jt}" ("${myCol}", "${otherCol}") VALUES (?, ?)`);
376
- for (const otherId of ids) ins.run(recordId, otherId);
541
+ const insertSql = buildInsertOrIgnoreSql(jt, myCol, otherCol);
542
+ for (const otherId of ids) {
543
+ await queryRun(insertSql, [recordId, otherId]);
544
+ }
377
545
  }
378
546
  }
379
547
 
548
+ async function closeDb() {
549
+ if (_pgPool) { try { await _pgPool.end(); } finally { _pgPool = null; } }
550
+ if (_mysqlPool) { try { await _mysqlPool.end(); } finally { _mysqlPool = null; } }
551
+ if (_sqliteDb) { try { _sqliteDb.close(); } finally { _sqliteDb = null; } }
552
+ }
553
+
380
554
  module.exports = {
381
- initDb, syncSchema, getDb, generateUUID,
555
+ initDb, syncSchema, getDb, generateUUID, closeDb,
556
+ exec, queryAll, queryOne, queryRun,
382
557
  findAll, findAllSimple, findById, create, update, remove,
383
558
  loadRelations, saveBelongsToMany,
384
559
  };