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/.devcontainer/devcontainer.json +34 -0
- package/.env.example +30 -1
- package/.github/workflows/db-integration.yml +139 -0
- package/.github/workflows/npm-chadstart.yml +12 -1
- package/.github/workflows/npm-sdk.yml +12 -1
- package/chadstart.example.yml +52 -0
- package/chadstart.schema.json +62 -0
- package/core/api-generator.js +36 -36
- package/core/auth.js +76 -65
- package/core/db.js +324 -149
- package/core/entity-engine.js +1 -0
- package/core/oauth.js +263 -0
- package/core/seeder.js +3 -3
- package/docs/auth.md +3 -0
- package/docs/config.md +8 -8
- package/docs/oauth.md +869 -0
- package/mkdocs.yml +1 -0
- package/package.json +5 -1
- package/server/express-server.js +20 -18
- package/test/access-policies.test.js +8 -8
- package/test/api-keys.test.js +28 -28
- package/test/auth.test.js +18 -18
- package/test/db.test.js +71 -71
- package/test/groups.test.js +5 -5
- package/test/integration/db-integration.test.js +368 -0
- package/test/middleware.test.js +1 -1
- package/test/oauth.test.js +259 -0
- package/test/sdk.test.js +19 -19
- package/test/seeder.test.js +26 -26
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
100
|
-
.replace(/\
|
|
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',
|
|
108
|
-
cols.push({ name: 'password', def: '
|
|
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:
|
|
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:
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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:
|
|
138
|
-
_neq: (col, val) => ({ sql:
|
|
139
|
-
_gt: (col, val) => ({ sql:
|
|
140
|
-
_gte: (col, val) => ({ sql:
|
|
141
|
-
_lt: (col, val) => ({ sql:
|
|
142
|
-
_lte: (col, val) => ({ sql:
|
|
143
|
-
_like: (col, val) => ({ sql:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
378
|
+
let sql = `SELECT * FROM ${q(table)}`;
|
|
199
379
|
if (clauses.length) sql += ` WHERE ${clauses.join(' AND ')}`;
|
|
200
380
|
|
|
201
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
240
|
-
const
|
|
241
|
-
const safe = Object.fromEntries(keys.filter((k) =>
|
|
242
|
-
if (!Object.keys(safe).length) return
|
|
243
|
-
const where = Object.keys(safe).map((k) =>
|
|
244
|
-
return
|
|
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
|
|
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) =>
|
|
433
|
+
const cols = keys.map((k) => q(k)).join(', ');
|
|
258
434
|
const ph = keys.map(() => '?').join(', ');
|
|
259
|
-
|
|
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) =>
|
|
269
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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)
|
|
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] =
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
538
|
+
await queryRun(`DELETE FROM ${q(jt)} WHERE ${q(myCol)} = ?`, [recordId]);
|
|
373
539
|
|
|
374
540
|
// Insert new
|
|
375
|
-
const
|
|
376
|
-
for (const otherId of ids)
|
|
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
|
};
|