chadstart 1.0.2 → 1.0.4
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/cli/cli.js +135 -6
- package/core/api-generator.js +2 -2
- package/core/auth.js +1 -4
- package/core/db.js +1 -0
- package/core/file-storage.js +13 -15
- package/core/migrations.js +433 -0
- package/core/oauth.js +2 -8
- package/core/seeder.js +2 -2
- package/docs/cli.md +41 -0
- package/docs/migrations.md +260 -0
- package/mkdocs.yml +1 -0
- package/package.json +2 -1
- package/test/migrations.test.js +498 -0
package/cli/cli.js
CHANGED
|
@@ -14,19 +14,26 @@ function printUsage() {
|
|
|
14
14
|
ChadStart - YAML-first Backend as a Service
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
|
-
npx chadstart dev
|
|
18
|
-
npx chadstart start
|
|
19
|
-
npx chadstart build
|
|
20
|
-
npx chadstart seed
|
|
17
|
+
npx chadstart dev Start server with hot-reload on YAML changes
|
|
18
|
+
npx chadstart start Start server (production mode)
|
|
19
|
+
npx chadstart build Validate YAML config and print schema summary
|
|
20
|
+
npx chadstart seed Seed the database with dummy data
|
|
21
|
+
npx chadstart migrate Run pending database migrations
|
|
22
|
+
npx chadstart migrate:generate Generate migration from YAML diff (git-based)
|
|
23
|
+
npx chadstart migrate:status Show current migration status
|
|
21
24
|
|
|
22
25
|
Options:
|
|
23
|
-
--config <file>
|
|
24
|
-
--port <number>
|
|
26
|
+
--config <file> Path to YAML config (default: chadstart.yaml)
|
|
27
|
+
--port <number> Override port from config
|
|
28
|
+
--migrations-dir <dir> Path to migrations directory (default: migrations)
|
|
29
|
+
--description <text> Description for generated migration
|
|
25
30
|
|
|
26
31
|
Examples:
|
|
27
32
|
npx chadstart dev
|
|
28
33
|
npx chadstart dev --config my-backend.yaml
|
|
29
34
|
npx chadstart start --port 8080
|
|
35
|
+
npx chadstart migrate:generate --description add-posts-table
|
|
36
|
+
npx chadstart migrate
|
|
30
37
|
`);
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -53,6 +60,12 @@ if (command === 'create') {
|
|
|
53
60
|
runBuild();
|
|
54
61
|
} else if (command === 'seed') {
|
|
55
62
|
runSeed();
|
|
63
|
+
} else if (command === 'migrate') {
|
|
64
|
+
runMigrate();
|
|
65
|
+
} else if (command === 'migrate:generate') {
|
|
66
|
+
runMigrateGenerate();
|
|
67
|
+
} else if (command === 'migrate:status') {
|
|
68
|
+
runMigrateStatus();
|
|
56
69
|
} else {
|
|
57
70
|
console.error(`Unknown command: ${command}`);
|
|
58
71
|
printUsage();
|
|
@@ -273,6 +286,122 @@ function runBuild() {
|
|
|
273
286
|
|
|
274
287
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
275
288
|
|
|
289
|
+
const migrationsDir = path.resolve(getOption('--migrations-dir') || 'migrations');
|
|
290
|
+
const migrationDescription = getOption('--description') || null;
|
|
291
|
+
|
|
292
|
+
async function runMigrate() {
|
|
293
|
+
if (!fs.existsSync(yamlPath)) {
|
|
294
|
+
console.error(`Config not found: ${yamlPath}`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const { loadYaml } = require('../core/yaml-loader');
|
|
300
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
301
|
+
const { buildCore } = require('../core/entity-engine');
|
|
302
|
+
const { initDb, closeDb } = require('../core/db');
|
|
303
|
+
const { runMigrations, buildExecQueryFn } = require('../core/migrations');
|
|
304
|
+
const dbModule = require('../core/db');
|
|
305
|
+
|
|
306
|
+
const config = loadYaml(yamlPath);
|
|
307
|
+
validateSchema(config);
|
|
308
|
+
const core = buildCore(config);
|
|
309
|
+
await initDb(core);
|
|
310
|
+
|
|
311
|
+
console.log('\n🔄 Running database migrations...\n');
|
|
312
|
+
|
|
313
|
+
const execQueryFn = buildExecQueryFn(dbModule);
|
|
314
|
+
|
|
315
|
+
const applied = await runMigrations(migrationsDir, execQueryFn);
|
|
316
|
+
|
|
317
|
+
if (applied.length === 0) {
|
|
318
|
+
console.log(' ✅ Database is up to date — no pending migrations.\n');
|
|
319
|
+
} else {
|
|
320
|
+
for (const m of applied) {
|
|
321
|
+
console.log(` ✅ Applied: ${m.version}.${m.action}${m.name ? '.' + m.name : ''}`);
|
|
322
|
+
}
|
|
323
|
+
console.log(`\n ${applied.length} migration${applied.length !== 1 ? 's' : ''} applied.\n`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await closeDb();
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error(`\n❌ ${err.message}\n`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function runMigrateGenerate() {
|
|
334
|
+
if (!fs.existsSync(yamlPath)) {
|
|
335
|
+
console.error(`Config not found: ${yamlPath}`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const { generateMigration } = require('../core/migrations');
|
|
341
|
+
|
|
342
|
+
console.log('\n📝 Generating migration from YAML diff...\n');
|
|
343
|
+
|
|
344
|
+
const result = generateMigration(yamlPath, migrationsDir, migrationDescription);
|
|
345
|
+
|
|
346
|
+
if (result.isEmpty) {
|
|
347
|
+
console.log(' ℹ️ No schema changes detected — nothing to generate.\n');
|
|
348
|
+
} else {
|
|
349
|
+
console.log(` ✅ Migration v${String(result.version).padStart(3, '0')} generated:`);
|
|
350
|
+
console.log(` DO: ${result.doPath}`);
|
|
351
|
+
console.log(` UNDO: ${result.undoPath}`);
|
|
352
|
+
console.log('\n Run `npx chadstart migrate` to apply.\n');
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.error(`\n❌ ${err.message}\n`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function runMigrateStatus() {
|
|
361
|
+
if (!fs.existsSync(yamlPath)) {
|
|
362
|
+
console.error(`Config not found: ${yamlPath}`);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const { loadYaml } = require('../core/yaml-loader');
|
|
368
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
369
|
+
const { buildCore } = require('../core/entity-engine');
|
|
370
|
+
const { initDb, closeDb } = require('../core/db');
|
|
371
|
+
const { getMigrationStatus, buildExecQueryFn } = require('../core/migrations');
|
|
372
|
+
const dbModule = require('../core/db');
|
|
373
|
+
|
|
374
|
+
const config = loadYaml(yamlPath);
|
|
375
|
+
validateSchema(config);
|
|
376
|
+
const core = buildCore(config);
|
|
377
|
+
await initDb(core);
|
|
378
|
+
|
|
379
|
+
const execQueryFn = buildExecQueryFn(dbModule);
|
|
380
|
+
|
|
381
|
+
const status = await getMigrationStatus(migrationsDir, execQueryFn);
|
|
382
|
+
|
|
383
|
+
console.log(`\n📊 Migration Status\n`);
|
|
384
|
+
console.log(` Current version: ${status.currentVersion}`);
|
|
385
|
+
console.log(` Applied: ${status.applied.length}`);
|
|
386
|
+
console.log(` Pending: ${status.pending.length}`);
|
|
387
|
+
|
|
388
|
+
if (status.pending.length > 0) {
|
|
389
|
+
console.log('\n Pending migrations:');
|
|
390
|
+
for (const m of status.pending) {
|
|
391
|
+
console.log(` - ${m.version}.${m.action}${m.name ? '.' + m.name : ''}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log('');
|
|
396
|
+
await closeDb();
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error(`\n❌ ${err.message}\n`);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Other helpers ───────────────────────────────────────────────────────────
|
|
404
|
+
|
|
276
405
|
function applyPortOverride() {
|
|
277
406
|
if (portOverride) {
|
|
278
407
|
process.env.CHADSTART_PORT = portOverride;
|
package/core/api-generator.js
CHANGED
|
@@ -310,10 +310,10 @@ function _apiKeyPermGuard(operation, entity) {
|
|
|
310
310
|
return (req, res, next) => {
|
|
311
311
|
if (!req._apiKeyPermissions) return next();
|
|
312
312
|
const { operations, entities: keyEntities } = req._apiKeyPermissions;
|
|
313
|
-
if (operations
|
|
313
|
+
if (operations?.length && !operations.includes(operation)) {
|
|
314
314
|
return res.status(403).json({ error: 'API key does not have permission for this operation' });
|
|
315
315
|
}
|
|
316
|
-
if (keyEntities
|
|
316
|
+
if (keyEntities?.length && !keyEntities.includes(entity.slug)) {
|
|
317
317
|
return res.status(403).json({ error: 'API key does not have access to this entity' });
|
|
318
318
|
}
|
|
319
319
|
next();
|
package/core/auth.js
CHANGED
|
@@ -16,6 +16,7 @@ const crypto = require('crypto');
|
|
|
16
16
|
const jwt = require('jsonwebtoken');
|
|
17
17
|
const bcrypt = require('bcryptjs');
|
|
18
18
|
const db = require('./db');
|
|
19
|
+
const { q: _q, DB_ENGINE: _DB_ENGINE } = db;
|
|
19
20
|
const logger = require('../utils/logger');
|
|
20
21
|
|
|
21
22
|
const API_KEY_PREFIX = 'cs_';
|
|
@@ -29,10 +30,6 @@ const JWT_SECRET = process.env.JWT_SECRET || process.env.TOKEN_SECRET_KEY || (()
|
|
|
29
30
|
const JWT_EXPIRES = process.env.JWT_EXPIRES || '7d';
|
|
30
31
|
const BCRYPT_ROUNDS = 10;
|
|
31
32
|
|
|
32
|
-
// Quote an identifier for the current database engine (mirrors db.js helper)
|
|
33
|
-
const _DB_ENGINE = (process.env.DB_ENGINE || 'sqlite').toLowerCase();
|
|
34
|
-
function _q(name) { return _DB_ENGINE === 'mysql' ? `\`${name}\`` : `"${name}"`; }
|
|
35
|
-
|
|
36
33
|
// Column types for the API keys table (must be indexable in all engines)
|
|
37
34
|
const _ID_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
|
|
38
35
|
const _HASH_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(64)' : 'TEXT';
|
package/core/db.js
CHANGED
|
@@ -552,6 +552,7 @@ async function closeDb() {
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
module.exports = {
|
|
555
|
+
DB_ENGINE, q, sqlType, idColType, authStrType, toPgPlaceholders,
|
|
555
556
|
initDb, syncSchema, getDb, generateUUID, closeDb,
|
|
556
557
|
exec, queryAll, queryOne, queryRun,
|
|
557
558
|
findAll, findAllSimple, findById, create, update, remove,
|
package/core/file-storage.js
CHANGED
|
@@ -4,6 +4,18 @@ const path = require('path');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const logger = require('../utils/logger');
|
|
7
|
+
const { sanitizeFilename } = require('./upload');
|
|
8
|
+
|
|
9
|
+
// Lazy-load busboy (shared with upload.js)
|
|
10
|
+
function getBusboy() {
|
|
11
|
+
try {
|
|
12
|
+
return require('busboy');
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'busboy is required for file uploads. Install it with: npm install busboy'
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
/**
|
|
9
21
|
* Register file storage routes for all buckets defined in core.files.
|
|
@@ -50,11 +62,7 @@ function registerFileRoutes(app, core) {
|
|
|
50
62
|
file.resume();
|
|
51
63
|
return;
|
|
52
64
|
}
|
|
53
|
-
|
|
54
|
-
const safeName = path
|
|
55
|
-
.basename(filename)
|
|
56
|
-
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
57
|
-
.replace(/^\.+/, '_');
|
|
65
|
+
const safeName = sanitizeFilename(filename);
|
|
58
66
|
const dest = path.join(bucketPath, safeName);
|
|
59
67
|
const writeStream = fs.createWriteStream(dest);
|
|
60
68
|
file.pipe(writeStream);
|
|
@@ -84,14 +92,4 @@ function registerFileRoutes(app, core) {
|
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
function getBusboy() {
|
|
88
|
-
try {
|
|
89
|
-
return require('busboy');
|
|
90
|
-
} catch {
|
|
91
|
-
throw new Error(
|
|
92
|
-
'busboy is required for file uploads. Install it with: npm install busboy'
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
95
|
module.exports = { registerFileRoutes };
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const YAML = require('yaml');
|
|
7
|
+
const logger = require('../utils/logger');
|
|
8
|
+
|
|
9
|
+
const { buildCore, toSnakeCase } = require('./entity-engine');
|
|
10
|
+
const { DB_ENGINE, q, sqlType, idColType, authStrType } = require('./db');
|
|
11
|
+
|
|
12
|
+
// ─── Git helpers ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Retrieve the last committed version of a file using git.
|
|
16
|
+
* Returns null if the file has no committed history (brand-new / untracked).
|
|
17
|
+
*/
|
|
18
|
+
function getLastCommittedYaml(yamlPath) {
|
|
19
|
+
try {
|
|
20
|
+
const resolved = path.resolve(yamlPath);
|
|
21
|
+
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
22
|
+
cwd: path.dirname(resolved),
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
}).toString().trim();
|
|
25
|
+
|
|
26
|
+
const relPath = path.relative(repoRoot, resolved);
|
|
27
|
+
|
|
28
|
+
const raw = execFileSync('git', ['show', `HEAD:${relPath}`], {
|
|
29
|
+
cwd: repoRoot,
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
}).toString();
|
|
32
|
+
|
|
33
|
+
return YAML.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load the current YAML file from disk and return the parsed object.
|
|
41
|
+
*/
|
|
42
|
+
function loadCurrentYaml(yamlPath) {
|
|
43
|
+
const resolved = path.resolve(yamlPath);
|
|
44
|
+
if (!fs.existsSync(resolved)) {
|
|
45
|
+
throw new Error(`YAML config not found: ${resolved}`);
|
|
46
|
+
}
|
|
47
|
+
return YAML.parse(fs.readFileSync(resolved, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
// ─── Diff engine ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare two core objects and return structured diff describing schema changes.
|
|
55
|
+
*
|
|
56
|
+
* Returns { newEntities, newColumns, newJunctionTables }.
|
|
57
|
+
*/
|
|
58
|
+
function diffCores(oldCore, newCore) {
|
|
59
|
+
const newEntities = [];
|
|
60
|
+
const newColumns = [];
|
|
61
|
+
const newJunctionTables = [];
|
|
62
|
+
|
|
63
|
+
const oldEntityMap = oldCore ? oldCore.entities : {};
|
|
64
|
+
|
|
65
|
+
for (const [name, entity] of Object.entries(newCore.entities)) {
|
|
66
|
+
const oldEntity = oldEntityMap[name];
|
|
67
|
+
|
|
68
|
+
if (!oldEntity) {
|
|
69
|
+
// Entirely new entity
|
|
70
|
+
newEntities.push(entity);
|
|
71
|
+
} else {
|
|
72
|
+
// Entity already exists — look for new properties
|
|
73
|
+
const oldPropNames = new Set(oldEntity.properties.map((p) => p.name));
|
|
74
|
+
const oldBelongsToNames = new Set(
|
|
75
|
+
(oldEntity.belongsTo || []).map((r) =>
|
|
76
|
+
typeof r === 'string' ? r : (r.entity || r.name)
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// New properties
|
|
81
|
+
for (const prop of entity.properties) {
|
|
82
|
+
if (entity.authenticable && (prop.name === 'email' || prop.name === 'password')) continue;
|
|
83
|
+
if (!oldPropNames.has(prop.name)) {
|
|
84
|
+
newColumns.push({ entity, prop });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// New belongsTo relations
|
|
89
|
+
for (const rel of entity.belongsTo || []) {
|
|
90
|
+
const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
|
|
91
|
+
if (!oldBelongsToNames.has(relName)) {
|
|
92
|
+
const refEntity = newCore.entities[relName];
|
|
93
|
+
if (refEntity) {
|
|
94
|
+
newColumns.push({
|
|
95
|
+
entity,
|
|
96
|
+
prop: { name: `${refEntity.tableName}_id`, type: '__fk__', refTable: refEntity.tableName },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// New authenticable flag (adds email + password columns)
|
|
103
|
+
if (entity.authenticable && !oldEntity.authenticable) {
|
|
104
|
+
if (!oldPropNames.has('email')) {
|
|
105
|
+
newColumns.push({ entity, prop: { name: 'email', type: '__auth_email__' } });
|
|
106
|
+
}
|
|
107
|
+
if (!oldPropNames.has('password')) {
|
|
108
|
+
newColumns.push({ entity, prop: { name: 'password', type: '__auth_password__' } });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// New belongsToMany junction tables
|
|
114
|
+
for (const rel of entity.belongsToMany || []) {
|
|
115
|
+
const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
|
|
116
|
+
const relEntity = newCore.entities[relName];
|
|
117
|
+
if (!relEntity) continue;
|
|
118
|
+
|
|
119
|
+
const [a, b] = [entity.tableName, relEntity.tableName].sort();
|
|
120
|
+
const jt = `${a}_${b}`;
|
|
121
|
+
|
|
122
|
+
// Check if old core had this junction
|
|
123
|
+
const oldJt = oldCore && oldEntityMap[name] &&
|
|
124
|
+
(oldEntityMap[name].belongsToMany || []).some((oldRel) => {
|
|
125
|
+
const oldRelName = typeof oldRel === 'string' ? oldRel : (oldRel.entity || oldRel.name);
|
|
126
|
+
return oldRelName === relName;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!oldJt) {
|
|
130
|
+
// Avoid duplicates (A→B and B→A produce the same junction)
|
|
131
|
+
if (!newJunctionTables.some((j) => j.tableName === jt)) {
|
|
132
|
+
newJunctionTables.push({
|
|
133
|
+
tableName: jt,
|
|
134
|
+
tableA: a,
|
|
135
|
+
tableB: b,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { newEntities, newColumns, newJunctionTables };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── SQL statement generation ─────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generate a CREATE TABLE SQL statement for a new entity.
|
|
149
|
+
*/
|
|
150
|
+
function generateCreateTableSql(entity, allEntities) {
|
|
151
|
+
const cols = [
|
|
152
|
+
`${q('id')} ${idColType()} PRIMARY KEY`,
|
|
153
|
+
`${q('createdAt')} TEXT`,
|
|
154
|
+
`${q('updatedAt')} TEXT`,
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
if (entity.authenticable) {
|
|
158
|
+
cols.push(`${q('email')} ${authStrType()} NOT NULL UNIQUE`);
|
|
159
|
+
cols.push(`${q('password')} ${authStrType()} NOT NULL`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const p of entity.properties) {
|
|
163
|
+
if (entity.authenticable && (p.name === 'email' || p.name === 'password')) continue;
|
|
164
|
+
cols.push(`${q(p.name)} ${sqlType(p.type)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const rel of entity.belongsTo || []) {
|
|
168
|
+
const relName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
|
|
169
|
+
const ref = allEntities[relName];
|
|
170
|
+
if (ref) {
|
|
171
|
+
const fk = `${ref.tableName}_id`;
|
|
172
|
+
cols.push(`${q(fk)} ${idColType()} REFERENCES ${q(ref.tableName)}(id)`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return `CREATE TABLE IF NOT EXISTS ${q(entity.tableName)} (${cols.join(', ')});`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate a DROP TABLE SQL statement for an entity.
|
|
181
|
+
*/
|
|
182
|
+
function generateDropTableSql(entity) {
|
|
183
|
+
return `DROP TABLE IF EXISTS ${q(entity.tableName)};`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Generate ALTER TABLE ADD COLUMN SQL for a new column.
|
|
188
|
+
*/
|
|
189
|
+
function generateAddColumnSql(entity, prop) {
|
|
190
|
+
let colDef;
|
|
191
|
+
if (prop.type === '__fk__') {
|
|
192
|
+
colDef = `${q(prop.name)} ${idColType()}`;
|
|
193
|
+
} else if (prop.type === '__auth_email__') {
|
|
194
|
+
colDef = `${q(prop.name)} ${authStrType()}`;
|
|
195
|
+
} else if (prop.type === '__auth_password__') {
|
|
196
|
+
colDef = `${q(prop.name)} ${authStrType()}`;
|
|
197
|
+
} else {
|
|
198
|
+
colDef = `${q(prop.name)} ${sqlType(prop.type)}`;
|
|
199
|
+
}
|
|
200
|
+
return `ALTER TABLE ${q(entity.tableName)} ADD COLUMN ${colDef};`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Generate CREATE TABLE SQL for a junction table.
|
|
205
|
+
*/
|
|
206
|
+
function generateCreateJunctionSql(junction) {
|
|
207
|
+
const { tableName, tableA, tableB } = junction;
|
|
208
|
+
const aCol = `${q(`${tableA}_id`)} ${idColType()} REFERENCES ${q(tableA)}(id)`;
|
|
209
|
+
const bCol = `${q(`${tableB}_id`)} ${idColType()} REFERENCES ${q(tableB)}(id)`;
|
|
210
|
+
return `CREATE TABLE IF NOT EXISTS ${q(tableName)} (${aCol}, ${bCol}, PRIMARY KEY (${q(`${tableA}_id`)}, ${q(`${tableB}_id`)}));`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Generate DROP TABLE SQL for a junction table.
|
|
215
|
+
*/
|
|
216
|
+
function generateDropJunctionSql(junction) {
|
|
217
|
+
return `DROP TABLE IF EXISTS ${q(junction.tableName)};`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Migration file generation ────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Given a diff, generate the "do" (up) and "undo" (down) SQL scripts.
|
|
224
|
+
*/
|
|
225
|
+
function generateMigrationScripts(diff, allEntities) {
|
|
226
|
+
const doStatements = [];
|
|
227
|
+
const undoStatements = [];
|
|
228
|
+
|
|
229
|
+
// New entities
|
|
230
|
+
for (const entity of diff.newEntities) {
|
|
231
|
+
doStatements.push(generateCreateTableSql(entity, allEntities));
|
|
232
|
+
undoStatements.push(generateDropTableSql(entity));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// New columns
|
|
236
|
+
for (const { entity, prop } of diff.newColumns) {
|
|
237
|
+
doStatements.push(generateAddColumnSql(entity, prop));
|
|
238
|
+
// Most databases don't support DROP COLUMN easily (especially SQLite),
|
|
239
|
+
// so undo for columns is a comment placeholder.
|
|
240
|
+
undoStatements.push(`-- ALTER TABLE ${q(entity.tableName)} DROP COLUMN ${q(prop.name)};`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// New junction tables
|
|
244
|
+
for (const jt of diff.newJunctionTables) {
|
|
245
|
+
doStatements.push(generateCreateJunctionSql(jt));
|
|
246
|
+
undoStatements.push(generateDropJunctionSql(jt));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
do: doStatements.join('\n'),
|
|
251
|
+
undo: undoStatements.join('\n'),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Determine the next migration version number from files in a directory.
|
|
257
|
+
*/
|
|
258
|
+
function getNextVersion(migrationsDir) {
|
|
259
|
+
if (!fs.existsSync(migrationsDir)) return 1;
|
|
260
|
+
|
|
261
|
+
const files = fs.readdirSync(migrationsDir).filter((f) => /^\d+\./.test(f));
|
|
262
|
+
if (!files.length) return 1;
|
|
263
|
+
|
|
264
|
+
const versions = files.map((f) => parseInt(f.split('.')[0], 10));
|
|
265
|
+
return Math.max(...versions) + 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Write migration SQL files to the migrations directory.
|
|
270
|
+
* Returns the paths of files written.
|
|
271
|
+
*/
|
|
272
|
+
function writeMigrationFiles(migrationsDir, doSql, undoSql, description) {
|
|
273
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
274
|
+
|
|
275
|
+
const version = String(getNextVersion(migrationsDir)).padStart(3, '0');
|
|
276
|
+
const desc = description ? `.${description.replace(/[^a-zA-Z0-9_-]/g, '-')}` : '';
|
|
277
|
+
|
|
278
|
+
const doFile = `${version}.do${desc}.sql`;
|
|
279
|
+
const undoFile = `${version}.undo${desc}.sql`;
|
|
280
|
+
|
|
281
|
+
const doPath = path.join(migrationsDir, doFile);
|
|
282
|
+
const undoPath = path.join(migrationsDir, undoFile);
|
|
283
|
+
|
|
284
|
+
fs.writeFileSync(doPath, doSql, 'utf8');
|
|
285
|
+
fs.writeFileSync(undoPath, undoSql, 'utf8');
|
|
286
|
+
|
|
287
|
+
return { doPath, undoPath, version: parseInt(version, 10) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Postgrator integration ──────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build an execQuery function suitable for postgrator from the db module.
|
|
294
|
+
*
|
|
295
|
+
* Postgrator calls execQuery for ALL queries (SELECT, CREATE, INSERT, ALTER, etc.)
|
|
296
|
+
* and always expects `{ rows: [...] }` back. For non-SELECT statements on SQLite,
|
|
297
|
+
* better-sqlite3's `.prepare().all()` throws, so we catch and return `{ rows: [] }`.
|
|
298
|
+
*/
|
|
299
|
+
function buildExecQueryFn(dbModule) {
|
|
300
|
+
return async function execQuery(query) {
|
|
301
|
+
try {
|
|
302
|
+
const rows = await dbModule.queryAll(query);
|
|
303
|
+
return { rows };
|
|
304
|
+
} catch {
|
|
305
|
+
// Non-SELECT statement (CREATE TABLE, INSERT, ALTER TABLE, DELETE, etc.)
|
|
306
|
+
await dbModule.exec(query);
|
|
307
|
+
return { rows: [] };
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Create a Postgrator instance configured for the current database engine.
|
|
314
|
+
* Uses dynamic import because postgrator is an ES module.
|
|
315
|
+
*/
|
|
316
|
+
async function createPostgrator(migrationsDir, execQueryFn) {
|
|
317
|
+
const { default: Postgrator } = await import('postgrator');
|
|
318
|
+
|
|
319
|
+
const driver = DB_ENGINE === 'postgres' ? 'pg'
|
|
320
|
+
: DB_ENGINE === 'mysql' ? 'mysql'
|
|
321
|
+
: 'sqlite3';
|
|
322
|
+
|
|
323
|
+
return new Postgrator({
|
|
324
|
+
migrationPattern: path.join(migrationsDir, '*'),
|
|
325
|
+
driver,
|
|
326
|
+
database: process.env.DB_DATABASE || 'chadstart',
|
|
327
|
+
schemaTable: '_cs_migrations',
|
|
328
|
+
execQuery: execQueryFn,
|
|
329
|
+
validateChecksum: true,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Run all pending migrations up to the latest version.
|
|
335
|
+
*/
|
|
336
|
+
async function runMigrations(migrationsDir, execQueryFn) {
|
|
337
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
338
|
+
logger.info('No migrations directory found — nothing to run.');
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const postgrator = await createPostgrator(migrationsDir, execQueryFn);
|
|
343
|
+
const applied = await postgrator.migrate();
|
|
344
|
+
return applied;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get the current migration version.
|
|
349
|
+
*/
|
|
350
|
+
async function getMigrationVersion(migrationsDir, execQueryFn) {
|
|
351
|
+
const postgrator = await createPostgrator(migrationsDir, execQueryFn);
|
|
352
|
+
return postgrator.getDatabaseVersion();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get all migrations and their status.
|
|
357
|
+
*/
|
|
358
|
+
async function getMigrationStatus(migrationsDir, execQueryFn) {
|
|
359
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
360
|
+
return { currentVersion: 0, pending: [], applied: [] };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const postgrator = await createPostgrator(migrationsDir, execQueryFn);
|
|
364
|
+
const currentVersion = await postgrator.getDatabaseVersion();
|
|
365
|
+
const allMigrations = await postgrator.getMigrations();
|
|
366
|
+
|
|
367
|
+
const doMigrations = allMigrations.filter((m) => m.action === 'do');
|
|
368
|
+
const applied = doMigrations.filter((m) => m.version <= currentVersion);
|
|
369
|
+
const pending = doMigrations.filter((m) => m.version > currentVersion);
|
|
370
|
+
|
|
371
|
+
return { currentVersion, pending, applied };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── High-level commands ──────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Generate a migration by diffing the current YAML against the last committed
|
|
378
|
+
* version in git. Writes numbered SQL files to the migrations directory.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} yamlPath Path to the chadstart YAML config file.
|
|
381
|
+
* @param {string} migrationsDir Path to the migrations directory.
|
|
382
|
+
* @param {string} [description] Optional description for the migration.
|
|
383
|
+
* @returns {{ doPath, undoPath, version, isEmpty } | null}
|
|
384
|
+
*/
|
|
385
|
+
function generateMigration(yamlPath, migrationsDir, description) {
|
|
386
|
+
const currentConfig = loadCurrentYaml(yamlPath);
|
|
387
|
+
const oldConfig = getLastCommittedYaml(yamlPath);
|
|
388
|
+
|
|
389
|
+
const newCore = buildCore(currentConfig);
|
|
390
|
+
const oldCore = oldConfig ? buildCore(oldConfig) : null;
|
|
391
|
+
|
|
392
|
+
const diff = diffCores(oldCore, newCore);
|
|
393
|
+
|
|
394
|
+
const hasChanges =
|
|
395
|
+
diff.newEntities.length > 0 ||
|
|
396
|
+
diff.newColumns.length > 0 ||
|
|
397
|
+
diff.newJunctionTables.length > 0;
|
|
398
|
+
|
|
399
|
+
if (!hasChanges) {
|
|
400
|
+
return { isEmpty: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const scripts = generateMigrationScripts(diff, newCore.entities);
|
|
404
|
+
const result = writeMigrationFiles(migrationsDir, scripts.do, scripts.undo, description);
|
|
405
|
+
|
|
406
|
+
return { ...result, isEmpty: false };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = {
|
|
410
|
+
// Git helpers
|
|
411
|
+
getLastCommittedYaml,
|
|
412
|
+
loadCurrentYaml,
|
|
413
|
+
// Diff engine
|
|
414
|
+
diffCores,
|
|
415
|
+
// SQL generation
|
|
416
|
+
generateCreateTableSql,
|
|
417
|
+
generateDropTableSql,
|
|
418
|
+
generateAddColumnSql,
|
|
419
|
+
generateCreateJunctionSql,
|
|
420
|
+
generateDropJunctionSql,
|
|
421
|
+
generateMigrationScripts,
|
|
422
|
+
// File operations
|
|
423
|
+
getNextVersion,
|
|
424
|
+
writeMigrationFiles,
|
|
425
|
+
// Postgrator integration
|
|
426
|
+
buildExecQueryFn,
|
|
427
|
+
createPostgrator,
|
|
428
|
+
runMigrations,
|
|
429
|
+
getMigrationVersion,
|
|
430
|
+
getMigrationStatus,
|
|
431
|
+
// High-level
|
|
432
|
+
generateMigration,
|
|
433
|
+
};
|