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 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 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
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> Path to YAML config (default: chadstart.yaml)
24
- --port <number> Override port from config
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;
@@ -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 && operations.length > 0 && !operations.includes(operation)) {
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 && keyEntities.length > 0 && !keyEntities.includes(entity.slug)) {
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,
@@ -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
- // Sanitize filename — strip directory traversal and disallow problematic characters
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
+ };