@memberjunction/schema-engine 0.0.1 → 5.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DDLGenerator.d.ts +48 -0
- package/dist/DDLGenerator.d.ts.map +1 -0
- package/dist/DDLGenerator.js +159 -0
- package/dist/DDLGenerator.js.map +1 -0
- package/dist/MigrationFileWriter.d.ts +32 -0
- package/dist/MigrationFileWriter.d.ts.map +1 -0
- package/dist/MigrationFileWriter.js +69 -0
- package/dist/MigrationFileWriter.js.map +1 -0
- package/dist/RSUMetrics.d.ts +48 -0
- package/dist/RSUMetrics.d.ts.map +1 -0
- package/dist/RSUMetrics.js +102 -0
- package/dist/RSUMetrics.js.map +1 -0
- package/dist/RuntimeSchemaManager.d.ts +454 -0
- package/dist/RuntimeSchemaManager.d.ts.map +1 -0
- package/dist/RuntimeSchemaManager.js +1395 -0
- package/dist/RuntimeSchemaManager.js.map +1 -0
- package/dist/SchemaEngine.d.ts +51 -0
- package/dist/SchemaEngine.d.ts.map +1 -0
- package/dist/SchemaEngine.js +100 -0
- package/dist/SchemaEngine.js.map +1 -0
- package/dist/SchemaEvolution.d.ts +30 -0
- package/dist/SchemaEvolution.d.ts.map +1 -0
- package/dist/SchemaEvolution.js +95 -0
- package/dist/SchemaEvolution.js.map +1 -0
- package/dist/SchemaValidator.d.ts +22 -0
- package/dist/SchemaValidator.d.ts.map +1 -0
- package/dist/SchemaValidator.js +93 -0
- package/dist/SchemaValidator.js.map +1 -0
- package/dist/SoftFKConfigEmitter.d.ts +59 -0
- package/dist/SoftFKConfigEmitter.d.ts.map +1 -0
- package/dist/SoftFKConfigEmitter.js +87 -0
- package/dist/SoftFKConfigEmitter.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces.d.ts +175 -0
- package/dist/interfaces.d.ts.map +1 -0
- package/dist/interfaces.js +9 -0
- package/dist/interfaces.js.map +1 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +16 -0
- package/dist/utils.js.map +1 -0
- package/package.json +31 -7
- package/README.md +0 -45
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuntimeSchemaManager — Pipeline orchestrator for runtime schema changes.
|
|
3
|
+
*
|
|
4
|
+
* Chains: validate input → SchemaEngine.generateDDL() → write migration file →
|
|
5
|
+
* run migration → run CodeGen → restart MJAPI via PM2.
|
|
6
|
+
*
|
|
7
|
+
* Gated by ALLOW_RUNTIME_SCHEMA_UPDATE=1 environment variable.
|
|
8
|
+
* Hard-blocks CREATE/ALTER/DROP in __mj schema.
|
|
9
|
+
* In-memory concurrency mutex (one operation at a time).
|
|
10
|
+
*/
|
|
11
|
+
import { BaseSingleton } from '@memberjunction/global';
|
|
12
|
+
import { LogError, Metadata } from '@memberjunction/core';
|
|
13
|
+
import { Octokit } from '@octokit/rest';
|
|
14
|
+
import { RSUMetrics } from './RSUMetrics.js';
|
|
15
|
+
import { GetDialect } from './DDLGenerator.js';
|
|
16
|
+
import * as nodePath from 'node:path';
|
|
17
|
+
import { appendFileSync } from 'node:fs';
|
|
18
|
+
import * as childProcess from 'node:child_process';
|
|
19
|
+
/** Signal string used to detect lock-held condition in database messages. */
|
|
20
|
+
const RSU_LOCK_HELD_SIGNAL = 'RSU_LOCK_HELD';
|
|
21
|
+
// ─── Typed RSU Configuration ─────────────────────────────────────────
|
|
22
|
+
/** Centralized, typed access to all RSU environment variables with defaults. */
|
|
23
|
+
class RSUConfig {
|
|
24
|
+
get MigrationsPath() {
|
|
25
|
+
return process.env.RSU_MIGRATIONS_PATH || 'migrations/rsu';
|
|
26
|
+
}
|
|
27
|
+
get AdditionalSchemaInfoPath() {
|
|
28
|
+
return process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH || 'additionalSchemaInfo.json';
|
|
29
|
+
}
|
|
30
|
+
get DefaultSchema() {
|
|
31
|
+
return process.env.RSU_DEFAULT_SCHEMA || '__mj';
|
|
32
|
+
}
|
|
33
|
+
get WorkDir() {
|
|
34
|
+
return process.env.RSU_WORK_DIR || process.cwd();
|
|
35
|
+
}
|
|
36
|
+
/** Directory where CodeGen runs (needs node_modules). Defaults to WorkDir. */
|
|
37
|
+
get CodeGenDir() {
|
|
38
|
+
return process.env.RSU_CODEGEN_DIR || this.WorkDir;
|
|
39
|
+
}
|
|
40
|
+
get CodeGenTimeoutMs() {
|
|
41
|
+
return parseInt(process.env.RSU_CODEGEN_TIMEOUT_MS || '600000', 10);
|
|
42
|
+
}
|
|
43
|
+
get CodeGenCommand() {
|
|
44
|
+
return process.env.RSU_CODEGEN_COMMAND;
|
|
45
|
+
}
|
|
46
|
+
get CompileTimeoutMs() {
|
|
47
|
+
return parseInt(process.env.RSU_COMPILE_TIMEOUT_MS || '300000', 10);
|
|
48
|
+
}
|
|
49
|
+
get CompileCommand() {
|
|
50
|
+
return process.env.RSU_COMPILE_COMMAND;
|
|
51
|
+
}
|
|
52
|
+
get CompilePackages() {
|
|
53
|
+
return process.env.RSU_COMPILE_PACKAGES;
|
|
54
|
+
}
|
|
55
|
+
get PM2ProcessName() {
|
|
56
|
+
return process.env.RSU_PM2_PROCESS_NAME || 'mjapi';
|
|
57
|
+
}
|
|
58
|
+
get GitTargetBranch() {
|
|
59
|
+
return process.env.RSU_GIT_TARGET_BRANCH || 'next';
|
|
60
|
+
}
|
|
61
|
+
get GitRemote() {
|
|
62
|
+
return process.env.RSU_GIT_REMOTE || 'origin';
|
|
63
|
+
}
|
|
64
|
+
get GitUserName() {
|
|
65
|
+
return process.env.RSU_GIT_USER_NAME;
|
|
66
|
+
}
|
|
67
|
+
get GitUserEmail() {
|
|
68
|
+
return process.env.RSU_GIT_USER_EMAIL;
|
|
69
|
+
}
|
|
70
|
+
/** GitHub repo in "owner/repo" format for Octokit-only mode (no local .git). */
|
|
71
|
+
get GitHubRepo() {
|
|
72
|
+
return process.env.RSU_GITHUB_REPO;
|
|
73
|
+
}
|
|
74
|
+
get IsDBLockEnabled() {
|
|
75
|
+
return process.env.RSU_DB_LOCK_ENABLED === '1';
|
|
76
|
+
}
|
|
77
|
+
get IsAuditLogEnabled() {
|
|
78
|
+
return process.env.RSU_AUDIT_LOG_ENABLED !== '0';
|
|
79
|
+
}
|
|
80
|
+
get PendingWorkPath() {
|
|
81
|
+
return process.env.RSU_PENDING_WORK_PATH || '.rsu_pending';
|
|
82
|
+
}
|
|
83
|
+
get ProtectedSchemas() {
|
|
84
|
+
const envSchemas = process.env.RSU_PROTECTED_SCHEMAS;
|
|
85
|
+
return envSchemas ? envSchemas.split(',').map((s) => s.trim()) : [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const rsuConfig = new RSUConfig();
|
|
89
|
+
/**
|
|
90
|
+
* Regex patterns for detecting DDL operations on a given schema.
|
|
91
|
+
* We check for CREATE TABLE/VIEW/PROCEDURE/FUNCTION, ALTER TABLE, and DROP on the schema.
|
|
92
|
+
*/
|
|
93
|
+
function buildSchemaPatterns(schema) {
|
|
94
|
+
// Escape special regex characters in the schema name
|
|
95
|
+
const escaped = schema.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
return [
|
|
97
|
+
// Match: CREATE TABLE schema. (with optional bracket/quote quoting)
|
|
98
|
+
new RegExp(`CREATE\\s+TABLE\\s+(?:\\[?|"?)${escaped}(?:\\]?|"?)\\.`, 'gi'),
|
|
99
|
+
// Match: ALTER TABLE schema.
|
|
100
|
+
new RegExp(`ALTER\\s+TABLE\\s+(?:\\[?|"?)${escaped}(?:\\]?|"?)\\.`, 'gi'),
|
|
101
|
+
// Match: DROP TABLE/VIEW/PROCEDURE/FUNCTION/SCHEMA ... schema.
|
|
102
|
+
new RegExp(`DROP\\s+(?:TABLE|VIEW|PROCEDURE|FUNCTION|SCHEMA)\\s+(?:IF\\s+EXISTS\\s+)?(?:\\[?|"?)${escaped}(?:\\]?|"?)(?:\\.|\\s|$)`, 'gi'),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Validate that migration SQL does not target protected schemas.
|
|
107
|
+
* The __mj schema is ALWAYS protected regardless of configuration.
|
|
108
|
+
*/
|
|
109
|
+
export function ValidateMigrationSQL(sql, additionalProtectedSchemas = []) {
|
|
110
|
+
const errors = [];
|
|
111
|
+
const allProtected = new Set(['__mj', ...additionalProtectedSchemas.map((s) => s.toLowerCase())]);
|
|
112
|
+
for (const schema of allProtected) {
|
|
113
|
+
const patterns = buildSchemaPatterns(schema);
|
|
114
|
+
for (const pattern of patterns) {
|
|
115
|
+
if (pattern.test(sql)) {
|
|
116
|
+
errors.push(`Migration SQL targets protected schema "${schema}". RSU cannot CREATE, ALTER, or DROP objects in this schema.`);
|
|
117
|
+
break; // One error per schema is enough
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { Valid: errors.length === 0, Errors: errors };
|
|
122
|
+
}
|
|
123
|
+
// ─── RuntimeSchemaManager ────────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Singleton pipeline orchestrator for runtime schema changes.
|
|
126
|
+
*
|
|
127
|
+
* Usage:
|
|
128
|
+
* const rsm = RuntimeSchemaManager.Instance;
|
|
129
|
+
* if (!rsm.IsEnabled) throw new Error('RSU is disabled');
|
|
130
|
+
* const result = await rsm.RunPipeline(input);
|
|
131
|
+
*/
|
|
132
|
+
export class RuntimeSchemaManager extends BaseSingleton {
|
|
133
|
+
constructor() {
|
|
134
|
+
super();
|
|
135
|
+
// ─── State ───────────────────────────────────────────────────────
|
|
136
|
+
this._isRunning = false;
|
|
137
|
+
this._ddlProvider = null;
|
|
138
|
+
this._codeGenRunner = null;
|
|
139
|
+
this._codeGenOutputPaths = [];
|
|
140
|
+
this._outOfSync = false;
|
|
141
|
+
this._outOfSyncSince = null;
|
|
142
|
+
this._lastRunAt = null;
|
|
143
|
+
this._lastRunResult = null;
|
|
144
|
+
/** Waiters queued behind the current lock holder. */
|
|
145
|
+
this._lockWaiters = [];
|
|
146
|
+
this._dbLockId = null;
|
|
147
|
+
}
|
|
148
|
+
static get Instance() {
|
|
149
|
+
return super.getInstance();
|
|
150
|
+
}
|
|
151
|
+
// ─── Public Properties ───────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Set a dedicated provider for DDL operations (CREATE TABLE, CREATE SCHEMA, etc.).
|
|
154
|
+
* Should be configured with elevated credentials (e.g. MJ_CodeGen with db_owner).
|
|
155
|
+
* If not set, falls back to the default Metadata.Provider.
|
|
156
|
+
*/
|
|
157
|
+
SetDDLProvider(provider) {
|
|
158
|
+
this._ddlProvider = provider;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Set the in-process CodeGen runner.
|
|
162
|
+
* Injected by the server at startup so RSU can run CodeGen without shelling out.
|
|
163
|
+
*/
|
|
164
|
+
SetCodeGenRunner(runner) {
|
|
165
|
+
this._codeGenRunner = runner;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Set the CodeGen output directory paths (from mj.config.cjs output[].directory).
|
|
169
|
+
* Used by the git step to stage only CodeGen-produced source files.
|
|
170
|
+
*/
|
|
171
|
+
SetCodeGenOutputPaths(paths) {
|
|
172
|
+
this._codeGenOutputPaths = paths;
|
|
173
|
+
}
|
|
174
|
+
// ─── Pending Work (post-restart tasks) ─────────────────────────
|
|
175
|
+
/** Write pending work to disk so it can be processed after restart. */
|
|
176
|
+
async WritePendingWork(data) {
|
|
177
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
178
|
+
const { join } = await import('node:path');
|
|
179
|
+
const dir = join(rsuConfig.WorkDir, rsuConfig.PendingWorkPath);
|
|
180
|
+
mkdirSync(dir, { recursive: true });
|
|
181
|
+
const filePath = join(dir, `${Date.now()}.json`);
|
|
182
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
183
|
+
this.rsuLog(`Wrote pending work to ${filePath}`);
|
|
184
|
+
return filePath;
|
|
185
|
+
}
|
|
186
|
+
/** Read all pending work files, return them, and delete them. */
|
|
187
|
+
async ReadAndClearPendingWork() {
|
|
188
|
+
const { readdirSync, readFileSync, unlinkSync, existsSync } = await import('node:fs');
|
|
189
|
+
const { join } = await import('node:path');
|
|
190
|
+
const dir = join(rsuConfig.WorkDir, rsuConfig.PendingWorkPath);
|
|
191
|
+
if (!existsSync(dir))
|
|
192
|
+
return [];
|
|
193
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
194
|
+
const results = [];
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
const filePath = join(dir, file);
|
|
197
|
+
try {
|
|
198
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
199
|
+
results.push(data);
|
|
200
|
+
unlinkSync(filePath);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* skip corrupt files */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (results.length > 0) {
|
|
207
|
+
this.rsuLog(`Found ${results.length} pending work item(s)`);
|
|
208
|
+
}
|
|
209
|
+
return results;
|
|
210
|
+
}
|
|
211
|
+
// ─── Generic Post-Restart File Injection ──────────────────────
|
|
212
|
+
/**
|
|
213
|
+
* Write PostRestartFiles from all pipeline inputs to disk.
|
|
214
|
+
* RSU does not interpret these — callers provide arbitrary files
|
|
215
|
+
* that need to survive the process restart.
|
|
216
|
+
*/
|
|
217
|
+
async writePostRestartFiles(inputs) {
|
|
218
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
219
|
+
const { dirname } = await import('node:path');
|
|
220
|
+
let count = 0;
|
|
221
|
+
for (const input of inputs) {
|
|
222
|
+
for (const file of input.PostRestartFiles ?? []) {
|
|
223
|
+
mkdirSync(dirname(file.Path), { recursive: true });
|
|
224
|
+
writeFileSync(file.Path, file.Content, 'utf-8');
|
|
225
|
+
count++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (count > 0) {
|
|
229
|
+
this.rsuLog(`Wrote ${count} post-restart file(s) to disk`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Whether RSU is enabled based on ALLOW_RUNTIME_SCHEMA_UPDATE=1. */
|
|
233
|
+
get IsEnabled() {
|
|
234
|
+
return process.env.ALLOW_RUNTIME_SCHEMA_UPDATE === '1';
|
|
235
|
+
}
|
|
236
|
+
/** Whether a pipeline is currently running (concurrency mutex). */
|
|
237
|
+
get IsRunning() {
|
|
238
|
+
return this._isRunning;
|
|
239
|
+
}
|
|
240
|
+
/** Whether the local API is out of sync with the git repo. */
|
|
241
|
+
get IsOutOfSync() {
|
|
242
|
+
return this._outOfSync;
|
|
243
|
+
}
|
|
244
|
+
/** When the out-of-sync state was first detected. */
|
|
245
|
+
get OutOfSyncSince() {
|
|
246
|
+
return this._outOfSyncSince;
|
|
247
|
+
}
|
|
248
|
+
/** When the last pipeline run completed. */
|
|
249
|
+
get LastRunAt() {
|
|
250
|
+
return this._lastRunAt;
|
|
251
|
+
}
|
|
252
|
+
/** Result of the last pipeline run. */
|
|
253
|
+
get LastRunResult() {
|
|
254
|
+
return this._lastRunResult;
|
|
255
|
+
}
|
|
256
|
+
/** Current status snapshot. */
|
|
257
|
+
GetStatus() {
|
|
258
|
+
return {
|
|
259
|
+
Enabled: this.IsEnabled,
|
|
260
|
+
Running: this.IsRunning,
|
|
261
|
+
OutOfSync: this.IsOutOfSync,
|
|
262
|
+
OutOfSyncSince: this._outOfSyncSince,
|
|
263
|
+
LastRunAt: this._lastRunAt,
|
|
264
|
+
LastRunResult: this._lastRunResult,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// ─── Pipeline ────────────────────────────────────────────────────
|
|
268
|
+
/**
|
|
269
|
+
* Execute the RSU pipeline for a single input. Convenience wrapper
|
|
270
|
+
* around RunPipelineBatch — a batch of 1.
|
|
271
|
+
*/
|
|
272
|
+
async RunPipeline(input) {
|
|
273
|
+
const batchResult = await this.RunPipelineBatch([input]);
|
|
274
|
+
return batchResult.Results[0];
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Execute the RSU pipeline for a batch of inputs.
|
|
278
|
+
*
|
|
279
|
+
* Correctness guarantee: migrations and CodeGen never overlap.
|
|
280
|
+
* All migrations execute under one lock, then CodeGen runs once
|
|
281
|
+
* against the fully-committed schema.
|
|
282
|
+
*
|
|
283
|
+
* Flow:
|
|
284
|
+
* 1. Validate all inputs (fail fast — reject entire batch on validation error)
|
|
285
|
+
* 2. Lock
|
|
286
|
+
* 3. For each input: write migration file → execute SQL (independent success/failure)
|
|
287
|
+
* 4. Unlock
|
|
288
|
+
* 5. ONE CodeGen run (covers all successful migrations)
|
|
289
|
+
* 6. ONE compile
|
|
290
|
+
* 7. ONE restart
|
|
291
|
+
* 8. ONE git commit (all migration files)
|
|
292
|
+
* 9. Resolve each caller with per-item migration result + shared post-migration result
|
|
293
|
+
*/
|
|
294
|
+
async RunPipelineBatch(inputs) {
|
|
295
|
+
if (inputs.length === 0)
|
|
296
|
+
return { Results: [], SuccessCount: 0, FailureCount: 0, TotalCount: 0 };
|
|
297
|
+
this.rsuLog(`═══════════════════════════════════════════════════`);
|
|
298
|
+
this.rsuLog(`Pipeline batch started — ${inputs.length} input(s)`);
|
|
299
|
+
for (const input of inputs) {
|
|
300
|
+
this.rsuLog(` • ${input.Description} [tables: ${input.AffectedTables.join(', ')}]`);
|
|
301
|
+
}
|
|
302
|
+
const sharedSteps = [];
|
|
303
|
+
// Phase 1: Validate
|
|
304
|
+
const validationFailure = await this.validateBatch(inputs, sharedSteps);
|
|
305
|
+
if (validationFailure)
|
|
306
|
+
return validationFailure;
|
|
307
|
+
// Phase 2: Execute migrations under lock
|
|
308
|
+
const itemResults = await this.executeMigrations(inputs, sharedSteps);
|
|
309
|
+
// Phase 3: Post-migration pipeline (CodeGen, compile, restart, git)
|
|
310
|
+
const successfulItems = itemResults.filter((r) => r.Success);
|
|
311
|
+
const postResult = await this.runPostMigrationPipeline(inputs, successfulItems, sharedSteps);
|
|
312
|
+
// Phase 4: Build per-caller results
|
|
313
|
+
return this.buildPerCallerResults(itemResults, successfulItems, sharedSteps, postResult);
|
|
314
|
+
}
|
|
315
|
+
/** Phase 1: Validate environment and all migration SQL. Returns a batch failure result if validation fails, null on success. */
|
|
316
|
+
async validateBatch(inputs, sharedSteps) {
|
|
317
|
+
const envStep = await this.runStep('ValidateEnvironment', () => this.validateEnvironment(), sharedSteps);
|
|
318
|
+
if (!envStep) {
|
|
319
|
+
return this.buildBatchResult(inputs.map((input) => this.buildFailedResult(input, 'ValidateEnvironment', sharedSteps)));
|
|
320
|
+
}
|
|
321
|
+
for (const input of inputs) {
|
|
322
|
+
const validation = ValidateMigrationSQL(input.MigrationSQL, this.getProtectedSchemas());
|
|
323
|
+
if (!validation.Valid) {
|
|
324
|
+
sharedSteps.push({ Name: 'ValidateSQL', Status: 'failed', DurationMs: 0, Message: validation.Errors.join('; ') });
|
|
325
|
+
return this.buildBatchResult(inputs.map((i) => this.buildFailedResult(i, 'ValidateSQL', sharedSteps)));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
sharedSteps.push({ Name: 'ValidateSQL', Status: 'success', DurationMs: 0, Message: `Validated ${inputs.length} migration(s)` });
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
/** Phase 2: Acquire lock, write and execute each migration, release lock. */
|
|
332
|
+
async executeMigrations(inputs, sharedSteps) {
|
|
333
|
+
const lockStep = await this.runStep('AcquireLock', () => this.acquireLock(), sharedSteps);
|
|
334
|
+
if (!lockStep) {
|
|
335
|
+
return inputs.map((input) => ({ Input: input, Success: false, Error: 'Failed to acquire lock', Steps: [] }));
|
|
336
|
+
}
|
|
337
|
+
const itemResults = [];
|
|
338
|
+
try {
|
|
339
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
340
|
+
const input = inputs[i];
|
|
341
|
+
this.rsuLog(`── Migration ${i + 1}/${inputs.length}: ${input.Description} [${input.AffectedTables.join(', ')}]`);
|
|
342
|
+
const itemSteps = [];
|
|
343
|
+
const writeStep = await this.runStep('WriteMigrationFile', () => this.writeMigrationFile(input), itemSteps);
|
|
344
|
+
if (!writeStep) {
|
|
345
|
+
itemResults.push({ Input: input, Success: false, Error: itemSteps.find((s) => s.Status === 'failed')?.Message, Steps: itemSteps });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const filePath = writeStep;
|
|
349
|
+
const execStep = await this.runStep('ExecuteMigration', () => this.executeMigration(input.MigrationSQL), itemSteps);
|
|
350
|
+
if (!execStep) {
|
|
351
|
+
itemResults.push({
|
|
352
|
+
Input: input,
|
|
353
|
+
FilePath: filePath,
|
|
354
|
+
Success: false,
|
|
355
|
+
Error: itemSteps.find((s) => s.Status === 'failed')?.Message,
|
|
356
|
+
Steps: itemSteps,
|
|
357
|
+
});
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
itemResults.push({ Input: input, FilePath: filePath, Success: true, Steps: itemSteps });
|
|
361
|
+
}
|
|
362
|
+
if (itemResults.some((r) => r.Success)) {
|
|
363
|
+
this.MarkOutOfSync();
|
|
364
|
+
sharedSteps.push({ Name: 'MarkOutOfSync', Status: 'success', DurationMs: 0, Message: 'DB changed, API out-of-sync until CodeGen completes' });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
await this.releaseLock();
|
|
369
|
+
}
|
|
370
|
+
return itemResults;
|
|
371
|
+
}
|
|
372
|
+
/** Phase 3: CodeGen, compile, restart, and git commit for successful migrations. */
|
|
373
|
+
async runPostMigrationPipeline(inputs, successfulItems, sharedSteps) {
|
|
374
|
+
const result = { ApiRestarted: false, GitCommitSuccess: false };
|
|
375
|
+
if (successfulItems.length === 0)
|
|
376
|
+
return result;
|
|
377
|
+
await this.runStep('WriteAdditionalSchemaInfo', () => this.writeAdditionalSchemaInfo(successfulItems.map((r) => r.Input)), sharedSteps);
|
|
378
|
+
const codegenOk = await this.runStep('RunCodeGen', () => this.runCodeGen(), sharedSteps);
|
|
379
|
+
if (codegenOk) {
|
|
380
|
+
const compileOk = await this.runStep('CompileTypeScript', () => this.compileTypeScript(), sharedSteps);
|
|
381
|
+
if (compileOk) {
|
|
382
|
+
this.ClearOutOfSync();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Git commit + PR before restart — PM2 restart kills this process
|
|
386
|
+
if (!inputs.every((i) => i.SkipGitCommit)) {
|
|
387
|
+
const allTables = [...new Set(successfulItems.flatMap((r) => r.Input.AffectedTables))];
|
|
388
|
+
const allFiles = successfulItems.map((r) => r.FilePath).filter((p) => !!p);
|
|
389
|
+
const description = successfulItems.length === 1 ? successfulItems[0].Input.Description : `Batch of ${successfulItems.length} schema changes`;
|
|
390
|
+
const gitStep = await this.runStep('GitCommitAndPR', () => this.gitCommitAndPRForCycle(allFiles, allTables, description), sharedSteps);
|
|
391
|
+
if (gitStep) {
|
|
392
|
+
result.GitCommitSuccess = true;
|
|
393
|
+
result.BranchName = gitStep;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Write caller-provided PostRestartFiles ONLY for successful migrations.
|
|
397
|
+
// Failed migrations should not trigger post-restart entity maps or syncs.
|
|
398
|
+
const successfulInputs = successfulItems.map(r => r.Input);
|
|
399
|
+
await this.writePostRestartFiles(successfulInputs);
|
|
400
|
+
// Restart LAST — PM2 restart kills this process, nothing runs after this
|
|
401
|
+
if (!inputs.every((i) => i.SkipRestart)) {
|
|
402
|
+
const restartOk = await this.runStep('RestartMJAPI', () => this.restartMJAPI(), sharedSteps);
|
|
403
|
+
if (restartOk)
|
|
404
|
+
result.ApiRestarted = true;
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
/** Phase 4: Assemble per-caller RSUPipelineResult entries with audit and metrics. */
|
|
409
|
+
buildPerCallerResults(itemResults, successfulItems, sharedSteps, postResult) {
|
|
410
|
+
this._lastRunAt = new Date();
|
|
411
|
+
this._lastRunResult = successfulItems.length > 0 ? 'success' : 'failed';
|
|
412
|
+
const results = itemResults.map((item) => {
|
|
413
|
+
const allSteps = [...sharedSteps, ...item.Steps];
|
|
414
|
+
const result = {
|
|
415
|
+
Success: item.Success && successfulItems.length > 0,
|
|
416
|
+
MigrationFilePath: item.FilePath,
|
|
417
|
+
APIRestarted: postResult.ApiRestarted,
|
|
418
|
+
GitCommitSuccess: postResult.GitCommitSuccess,
|
|
419
|
+
BranchName: postResult.BranchName,
|
|
420
|
+
Steps: allSteps,
|
|
421
|
+
ErrorMessage: item.Error,
|
|
422
|
+
ErrorStep: item.Error ? item.Steps.find((s) => s.Status === 'failed')?.Name : undefined,
|
|
423
|
+
};
|
|
424
|
+
this.writeAuditLog(item.Input, result).catch((err) => LogError(`[RSU] Audit log failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
425
|
+
this.recordMetrics(item, allSteps, result);
|
|
426
|
+
return result;
|
|
427
|
+
});
|
|
428
|
+
return this.buildBatchResult(results);
|
|
429
|
+
}
|
|
430
|
+
/** Record metrics for a single pipeline item (best-effort, errors are logged). */
|
|
431
|
+
recordMetrics(item, allSteps, result) {
|
|
432
|
+
try {
|
|
433
|
+
const stepDurations = {};
|
|
434
|
+
for (const step of allSteps)
|
|
435
|
+
stepDurations[step.Name] = step.DurationMs;
|
|
436
|
+
RSUMetrics.Instance.RecordRun({
|
|
437
|
+
Timestamp: new Date(),
|
|
438
|
+
DurationMs: allSteps.reduce((sum, s) => sum + s.DurationMs, 0),
|
|
439
|
+
Success: result.Success,
|
|
440
|
+
StepDurations: stepDurations,
|
|
441
|
+
ErrorStep: result.ErrorStep,
|
|
442
|
+
Description: item.Input.Description,
|
|
443
|
+
AffectedTables: item.Input.AffectedTables,
|
|
444
|
+
RetryCount: 0,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
LogError(`[RSU] Metrics recording failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Run the RSU pipeline with automatic retry for transient failures.
|
|
453
|
+
* Only retries steps that are known to be transient (SQL execution, CodeGen, compilation, restart).
|
|
454
|
+
* Validation and git steps are not retried.
|
|
455
|
+
*
|
|
456
|
+
* @param input Pipeline input
|
|
457
|
+
* @param maxRetries Maximum number of retries (default: 2, so 3 total attempts)
|
|
458
|
+
* @param baseDelayMs Base delay for exponential backoff (default: 5000ms)
|
|
459
|
+
*/
|
|
460
|
+
async RunPipelineWithRetry(input, maxRetries = 2, baseDelayMs = 5000) {
|
|
461
|
+
const RETRYABLE_STEPS = new Set(['ExecuteMigration', 'RunCodeGen', 'CompileTypeScript', 'RestartMJAPI']);
|
|
462
|
+
let lastResult = null;
|
|
463
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
464
|
+
const result = await this.RunPipeline(input);
|
|
465
|
+
if (result.Success) {
|
|
466
|
+
if (attempt > 0) {
|
|
467
|
+
console.log(`[RSU] Pipeline succeeded on retry attempt ${attempt}`);
|
|
468
|
+
}
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
lastResult = result;
|
|
472
|
+
// Check if the failed step is retryable
|
|
473
|
+
const failedStep = result.ErrorStep;
|
|
474
|
+
if (!failedStep || !RETRYABLE_STEPS.has(failedStep)) {
|
|
475
|
+
console.log(`[RSU] Step '${failedStep}' is not retryable — aborting`);
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
if (attempt >= maxRetries) {
|
|
479
|
+
console.log(`[RSU] Max retries (${maxRetries}) exhausted — aborting`);
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt);
|
|
483
|
+
console.log(`[RSU] Step '${failedStep}' failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delayMs}ms...`);
|
|
484
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
485
|
+
}
|
|
486
|
+
return lastResult;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Dry-run: validate the SQL and return what would happen.
|
|
490
|
+
*/
|
|
491
|
+
Preview(input) {
|
|
492
|
+
const validation = ValidateMigrationSQL(input.MigrationSQL, this.getProtectedSchemas());
|
|
493
|
+
return {
|
|
494
|
+
MigrationSQL: input.MigrationSQL,
|
|
495
|
+
AffectedTables: input.AffectedTables,
|
|
496
|
+
ValidationErrors: validation.Errors,
|
|
497
|
+
WouldExecute: this.IsEnabled && validation.Valid,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// ─── Out-of-Sync Management ─────────────────────────────────────
|
|
501
|
+
/** Mark the API as running code that is out-of-sync with the git repo. */
|
|
502
|
+
MarkOutOfSync() {
|
|
503
|
+
this._outOfSync = true;
|
|
504
|
+
this._outOfSyncSince = new Date();
|
|
505
|
+
}
|
|
506
|
+
/** Clear the out-of-sync flag (called when CI/CD deployment lands). */
|
|
507
|
+
ClearOutOfSync() {
|
|
508
|
+
this._outOfSync = false;
|
|
509
|
+
this._outOfSyncSince = null;
|
|
510
|
+
}
|
|
511
|
+
// ─── Pipeline Step Implementations ──────────────────────────────
|
|
512
|
+
async validateEnvironment() {
|
|
513
|
+
if (!this.IsEnabled) {
|
|
514
|
+
throw new RSUError('DISABLED', 'Runtime Schema Update is disabled. Set ALLOW_RUNTIME_SCHEMA_UPDATE=1 to enable.');
|
|
515
|
+
}
|
|
516
|
+
await this.validateFilesystemWritable();
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Verify ALL filesystem paths RSU may write to are actually writable.
|
|
521
|
+
* Paths checked:
|
|
522
|
+
* - MigrationsPath (migration SQL files)
|
|
523
|
+
* - PendingWorkPath (post-restart task JSONs)
|
|
524
|
+
* - AdditionalSchemaInfoPath parent dir (soft FK / soft PK config)
|
|
525
|
+
* - CodeGenDir (temp codegen script)
|
|
526
|
+
* - WorkDir (pipeline log file)
|
|
527
|
+
*/
|
|
528
|
+
async validateFilesystemWritable() {
|
|
529
|
+
const { writeFileSync, unlinkSync, mkdirSync, existsSync } = await import('node:fs');
|
|
530
|
+
const { dirname } = await import('node:path');
|
|
531
|
+
const workDir = rsuConfig.WorkDir;
|
|
532
|
+
const directoriesToCheck = [
|
|
533
|
+
nodePath.join(workDir, rsuConfig.MigrationsPath),
|
|
534
|
+
nodePath.join(workDir, rsuConfig.PendingWorkPath),
|
|
535
|
+
dirname(nodePath.join(workDir, rsuConfig.AdditionalSchemaInfoPath)),
|
|
536
|
+
rsuConfig.CodeGenDir,
|
|
537
|
+
workDir, // pipeline log
|
|
538
|
+
];
|
|
539
|
+
// Deduplicate (e.g. CodeGenDir may equal WorkDir)
|
|
540
|
+
const uniqueDirs = [...new Set(directoriesToCheck.map(d => nodePath.resolve(d)))];
|
|
541
|
+
for (const dir of uniqueDirs) {
|
|
542
|
+
if (!existsSync(dir)) {
|
|
543
|
+
try {
|
|
544
|
+
mkdirSync(dir, { recursive: true });
|
|
545
|
+
}
|
|
546
|
+
catch (e) {
|
|
547
|
+
throw new RSUError('FILESYSTEM', `Cannot create directory ${dir}: ${e.message}. Ensure the filesystem is writable.`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const probe = nodePath.join(dir, `.rsu_write_probe_${Date.now()}`);
|
|
551
|
+
try {
|
|
552
|
+
writeFileSync(probe, '');
|
|
553
|
+
unlinkSync(probe);
|
|
554
|
+
this.rsuLog(`✓ Filesystem writable: ${dir}`);
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
throw new RSUError('FILESYSTEM', `Filesystem is not writable at ${dir}: ${e.message}. Ensure the directory is mounted read-write.`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Acquire the migration lock. If another migration is in progress,
|
|
563
|
+
* the caller awaits (event-driven, no polling) until the lock is free.
|
|
564
|
+
* This ensures concurrent RunPipeline calls serialize safely instead
|
|
565
|
+
* of throwing CONCURRENT errors.
|
|
566
|
+
*/
|
|
567
|
+
async acquireLock() {
|
|
568
|
+
// If lock is held, wait in line (event-driven via promise)
|
|
569
|
+
if (this._isRunning) {
|
|
570
|
+
await new Promise((resolve) => {
|
|
571
|
+
this._lockWaiters.push(resolve);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
this._isRunning = true;
|
|
575
|
+
// DB-backed lock for multi-instance safety (if enabled)
|
|
576
|
+
if (this.IsDBLockEnabled) {
|
|
577
|
+
const acquired = await this.acquireDBLock();
|
|
578
|
+
if (!acquired) {
|
|
579
|
+
// Another MJAPI instance holds the lock — release in-memory and reject
|
|
580
|
+
this._isRunning = false;
|
|
581
|
+
this.notifyNextWaiter();
|
|
582
|
+
throw new RSUError('CONCURRENT', 'Another RSU pipeline is running on a different instance. Wait for it to complete.');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
/** Wake the next waiter in line (if any). */
|
|
588
|
+
notifyNextWaiter() {
|
|
589
|
+
const next = this._lockWaiters.shift();
|
|
590
|
+
if (next)
|
|
591
|
+
next();
|
|
592
|
+
}
|
|
593
|
+
async releaseLock() {
|
|
594
|
+
// Release DB lock if enabled
|
|
595
|
+
if (this.IsDBLockEnabled) {
|
|
596
|
+
await this.releaseDBLock();
|
|
597
|
+
}
|
|
598
|
+
this._isRunning = false;
|
|
599
|
+
// Wake the next waiter in line
|
|
600
|
+
this.notifyNextWaiter();
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Write the migration file to the configured migrations directory.
|
|
604
|
+
* Returns the file path of the written migration.
|
|
605
|
+
*
|
|
606
|
+
* Writes the migration file to the configured migrations directory (RSU_MIGRATIONS_PATH,
|
|
607
|
+
* defaults to 'migrations/v5'). The git commit step then picks up the file.
|
|
608
|
+
*/
|
|
609
|
+
async writeMigrationFile(input) {
|
|
610
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
611
|
+
const { join } = await import('node:path');
|
|
612
|
+
const migrationsPath = join(rsuConfig.WorkDir, rsuConfig.MigrationsPath);
|
|
613
|
+
const timestamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12);
|
|
614
|
+
const tableSlug = input.AffectedTables.slice(0, 3)
|
|
615
|
+
.map((t) => t.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase())
|
|
616
|
+
.join('_');
|
|
617
|
+
const fileName = `V${timestamp}__RSU_${tableSlug}.sql`;
|
|
618
|
+
const filePath = `${migrationsPath}/${fileName}`;
|
|
619
|
+
// Ensure the migrations directory exists
|
|
620
|
+
mkdirSync(migrationsPath, { recursive: true });
|
|
621
|
+
// Build file content with header comment
|
|
622
|
+
const header = [
|
|
623
|
+
`-- RSU Migration: ${input.Description}`,
|
|
624
|
+
`-- Generated: ${new Date().toISOString()}`,
|
|
625
|
+
`-- Affected tables: ${input.AffectedTables.join(', ')}`,
|
|
626
|
+
'',
|
|
627
|
+
].join('\n');
|
|
628
|
+
writeFileSync(filePath, header + input.MigrationSQL, 'utf-8');
|
|
629
|
+
return filePath;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Write/merge AdditionalSchemaInfo (soft PK/FK config) to disk so CodeGen can find it.
|
|
633
|
+
* Path comes from RSU_ADDITIONAL_SCHEMA_INFO_PATH config, resolved relative to WorkDir.
|
|
634
|
+
*/
|
|
635
|
+
async writeAdditionalSchemaInfo(inputs) {
|
|
636
|
+
const contents = inputs.map((i) => i.AdditionalSchemaInfo).filter((c) => !!c);
|
|
637
|
+
if (contents.length === 0)
|
|
638
|
+
return true;
|
|
639
|
+
const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs');
|
|
640
|
+
const { join, dirname } = await import('node:path');
|
|
641
|
+
const configFilePath = join(rsuConfig.WorkDir, rsuConfig.AdditionalSchemaInfoPath);
|
|
642
|
+
// Load existing config or start fresh
|
|
643
|
+
// Format: { schemaName: [ { TableName, PrimaryKey?, ForeignKeys? }, ... ], ... }
|
|
644
|
+
let existing = {};
|
|
645
|
+
if (existsSync(configFilePath)) {
|
|
646
|
+
try {
|
|
647
|
+
existing = JSON.parse(readFileSync(configFilePath, 'utf-8'));
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
/* start fresh */
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Merge each incoming config (keyed by schema name) into existing
|
|
654
|
+
for (const content of contents) {
|
|
655
|
+
try {
|
|
656
|
+
const incoming = JSON.parse(content);
|
|
657
|
+
for (const [schemaName, tables] of Object.entries(incoming)) {
|
|
658
|
+
if (!Array.isArray(tables))
|
|
659
|
+
continue;
|
|
660
|
+
if (!existing[schemaName])
|
|
661
|
+
existing[schemaName] = [];
|
|
662
|
+
for (const table of tables) {
|
|
663
|
+
const idx = existing[schemaName].findIndex((t) => t.TableName.toLowerCase() === table.TableName.toLowerCase());
|
|
664
|
+
if (idx >= 0)
|
|
665
|
+
existing[schemaName][idx] = table;
|
|
666
|
+
else
|
|
667
|
+
existing[schemaName].push(table);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
/* skip unparseable */
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
mkdirSync(dirname(configFilePath), { recursive: true });
|
|
676
|
+
writeFileSync(configFilePath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
677
|
+
this.rsuLog(`Wrote additionalSchemaInfo to ${configFilePath}`);
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Execute the migration SQL against the database.
|
|
682
|
+
* Delegates CLI tool selection to the DBExecProvider abstraction.
|
|
683
|
+
*/
|
|
684
|
+
async executeMigration(sql) {
|
|
685
|
+
const defaultSchema = rsuConfig.DefaultSchema;
|
|
686
|
+
const resolvedSQL = sql.replace(/\$\{flyway:defaultSchema\}/g, defaultSchema);
|
|
687
|
+
// Split on GO batch separators (SQL Server SSMS convention, not valid T-SQL)
|
|
688
|
+
const batches = resolvedSQL
|
|
689
|
+
.split(/^\s*GO\s*$/gim)
|
|
690
|
+
.map((b) => b.trim())
|
|
691
|
+
.filter((b) => b.length > 0);
|
|
692
|
+
try {
|
|
693
|
+
for (const batch of batches) {
|
|
694
|
+
this.rsuLog(` Executing batch (${batch.length} chars)`);
|
|
695
|
+
await this.getDBProvider().ExecuteSQL(batch, undefined, { isMutation: true, description: 'RSU migration' });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
700
|
+
throw new RSUError('SQL_EXEC', msg);
|
|
701
|
+
}
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Run CodeGen programmatically.
|
|
706
|
+
*
|
|
707
|
+
* Writes a temporary .mjs script that imports CodeGenLib directly (bypassing
|
|
708
|
+
* the oclif-based `mj` CLI which has heavy dependencies). The script:
|
|
709
|
+
* 1. Loads dotenv/config for DB env vars
|
|
710
|
+
* 2. Bootstraps class registrations via server-bootstrap-lite
|
|
711
|
+
* 3. Initializes config from mj.config.cjs
|
|
712
|
+
* 4. Runs full CodeGen pipeline (metadata + SQL + TypeScript generation)
|
|
713
|
+
*
|
|
714
|
+
* Override via RSU_CODEGEN_COMMAND env var for custom setups.
|
|
715
|
+
*/
|
|
716
|
+
async runCodeGen() {
|
|
717
|
+
// Prefer in-process CodeGen runner if injected (no child process, no filesystem deps)
|
|
718
|
+
if (this._codeGenRunner) {
|
|
719
|
+
this.rsuLog('Running CodeGen in-process');
|
|
720
|
+
const success = await this._codeGenRunner.RunInProcess(false);
|
|
721
|
+
if (!success) {
|
|
722
|
+
throw new RSUError('CODEGEN', 'In-process CodeGen failed');
|
|
723
|
+
}
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
// Fallback: shell out to a temp script (legacy approach for environments without injection)
|
|
727
|
+
this.rsuLog('No in-process CodeGen runner — falling back to child process');
|
|
728
|
+
const { execAsync } = await this.getExecAsync();
|
|
729
|
+
const { writeFileSync, unlinkSync } = await import('node:fs');
|
|
730
|
+
const codegenDir = rsuConfig.CodeGenDir;
|
|
731
|
+
const timeoutMs = rsuConfig.CodeGenTimeoutMs;
|
|
732
|
+
const customCmd = rsuConfig.CodeGenCommand;
|
|
733
|
+
if (customCmd) {
|
|
734
|
+
await execAsync(`cd "${codegenDir}" && ${customCmd}`, { timeout: timeoutMs });
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
// Write a temp .mjs script inside the codegen dir so Node module resolution
|
|
738
|
+
// can find dotenv, codegen-lib, etc. from node_modules
|
|
739
|
+
const tmpScript = `${codegenDir}/.rsu_codegen_${Date.now()}.mjs`;
|
|
740
|
+
const scriptContent = [
|
|
741
|
+
"import 'dotenv/config';",
|
|
742
|
+
"await import('@memberjunction/server-bootstrap-lite/mj-class-registrations');",
|
|
743
|
+
"const { initializeConfig, runMemberJunctionCodeGeneration } = await import('@memberjunction/codegen-lib');",
|
|
744
|
+
'initializeConfig(process.cwd());',
|
|
745
|
+
'await runMemberJunctionCodeGeneration(false);',
|
|
746
|
+
].join('\n');
|
|
747
|
+
writeFileSync(tmpScript, scriptContent, 'utf-8');
|
|
748
|
+
try {
|
|
749
|
+
// Exit code 0 = success. No stdout string matching needed.
|
|
750
|
+
await execAsync(`cd "${codegenDir}" && node "${tmpScript}"`, { timeout: timeoutMs });
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
755
|
+
if (msg.includes('not found') || msg.includes('ENOENT') || msg.includes('MODULE_NOT_FOUND')) {
|
|
756
|
+
throw new RSUError('CODEGEN_NOT_AVAILABLE', `CodeGen not available. Ensure @memberjunction/codegen-lib and @memberjunction/server-bootstrap-lite are built. Error: ${msg.substring(0, 300)}`);
|
|
757
|
+
}
|
|
758
|
+
throw new RSUError('CODEGEN_FAILED', `CodeGen failed: ${msg.substring(0, 500)}`);
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
try {
|
|
762
|
+
unlinkSync(tmpScript);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
/* best-effort cleanup */
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Compile affected TypeScript packages after CodeGen.
|
|
771
|
+
*
|
|
772
|
+
* Uses turbo to build the specified packages. Turbo handles the full
|
|
773
|
+
* dependency graph and caches unchanged packages automatically.
|
|
774
|
+
*
|
|
775
|
+
* Default targets: MJCoreEntities, MJServer, MJAPI (the primary CodeGen outputs).
|
|
776
|
+
* Override via RSU_COMPILE_PACKAGES (comma-separated npm package names).
|
|
777
|
+
*
|
|
778
|
+
* Set RSU_COMPILE_COMMAND to override the entire build command (e.g. for
|
|
779
|
+
* environments without turbo).
|
|
780
|
+
*/
|
|
781
|
+
async compileTypeScript() {
|
|
782
|
+
const { execAsync } = await this.getExecAsync();
|
|
783
|
+
const codegenDir = rsuConfig.CodeGenDir;
|
|
784
|
+
const timeoutMs = rsuConfig.CompileTimeoutMs;
|
|
785
|
+
// Allow full command override
|
|
786
|
+
const compileCmd = rsuConfig.CompileCommand;
|
|
787
|
+
if (compileCmd) {
|
|
788
|
+
await execAsync(`cd "${codegenDir}" && ${compileCmd}`, { timeout: timeoutMs });
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
// Build using turbo with --filter for each package
|
|
792
|
+
const defaultPackages = '@memberjunction/core-entities,@memberjunction/server,mj_api';
|
|
793
|
+
const envPackages = rsuConfig.CompilePackages;
|
|
794
|
+
const rawPackages = envPackages !== undefined ? envPackages : defaultPackages;
|
|
795
|
+
const packageNames = rawPackages
|
|
796
|
+
.split(',')
|
|
797
|
+
.map((p) => p.trim())
|
|
798
|
+
.filter((p) => p.length > 0);
|
|
799
|
+
const filterArgs = packageNames.map((p) => `--filter="${p}"`).join(' ');
|
|
800
|
+
await execAsync(`cd "${codegenDir}" && npx turbo build ${filterArgs}`, { timeout: timeoutMs });
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Restart MJAPI via PM2.
|
|
805
|
+
*
|
|
806
|
+
* If MJAPI isn't currently managed by PM2, starts it using the ecosystem
|
|
807
|
+
* config. After restart, polls the GraphQL endpoint until the server is
|
|
808
|
+
* ready to accept requests.
|
|
809
|
+
*/
|
|
810
|
+
async restartMJAPI() {
|
|
811
|
+
const { execAsync } = await this.getExecAsync();
|
|
812
|
+
const workDir = rsuConfig.WorkDir;
|
|
813
|
+
const processName = rsuConfig.PM2ProcessName;
|
|
814
|
+
// When running inside the MJAPI process that PM2 manages, `pm2 restart`
|
|
815
|
+
// sends SIGINT to THIS process. We will die before execAsync resolves.
|
|
816
|
+
// That's fine — the pending-work file (.rsu_pending/) was already written
|
|
817
|
+
// before this step, and the newly restarted process picks it up on boot.
|
|
818
|
+
//
|
|
819
|
+
// Strategy: fire the restart command, catch the inevitable rejection
|
|
820
|
+
// (our process gets killed mid-flight), and return true. If the process
|
|
821
|
+
// somehow survives (e.g. running outside PM2), poll until MJAPI is ready.
|
|
822
|
+
const isManaged = await this.isPM2ProcessRunning(processName);
|
|
823
|
+
try {
|
|
824
|
+
if (isManaged) {
|
|
825
|
+
await execAsync(`pm2 restart ${processName}`, { timeout: 30_000 });
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
await execAsync(`cd "${workDir}" && pm2 start ecosystem.config.cjs`, { timeout: 30_000 });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
// Expected: pm2 restart killed us and we caught the signal, OR
|
|
833
|
+
// the command timed out because the process recycled. Either way,
|
|
834
|
+
// the restart was issued — the new process handles the rest.
|
|
835
|
+
this.rsuLog('pm2 restart command completed or process was recycled — treating as success');
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
// If we're still alive (running outside PM2, or PM2 didn't kill us),
|
|
839
|
+
// poll the endpoint to confirm the server is healthy.
|
|
840
|
+
return this.waitForMJAPI();
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Check if a PM2 process exists (running or stopped).
|
|
844
|
+
*/
|
|
845
|
+
async isPM2ProcessRunning(processName) {
|
|
846
|
+
const { execAsync } = await this.getExecAsync();
|
|
847
|
+
try {
|
|
848
|
+
const { stdout } = await execAsync(`pm2 jlist`, { timeout: 10_000 });
|
|
849
|
+
const processes = JSON.parse(stdout);
|
|
850
|
+
return processes.some((p) => p.name === processName);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Poll the MJAPI GraphQL endpoint until the server responds.
|
|
858
|
+
* Uses a simple GET to the server root — Apollo Server returns a landing
|
|
859
|
+
* page or 400 (both indicate the server is up and accepting connections).
|
|
860
|
+
*/
|
|
861
|
+
async waitForMJAPI() {
|
|
862
|
+
const maxWaitMs = 120_000; // 2 minutes — MJAPI can be slow to start
|
|
863
|
+
const startTime = Date.now();
|
|
864
|
+
const port = process.env.GRAPHQL_PORT || '4000';
|
|
865
|
+
const url = `http://localhost:${port}/`;
|
|
866
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
867
|
+
try {
|
|
868
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
|
869
|
+
// Any HTTP response means the server is up
|
|
870
|
+
if (response.status < 500)
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
/* server not ready yet */
|
|
875
|
+
}
|
|
876
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
877
|
+
}
|
|
878
|
+
throw new RSUError('RESTART_TIMEOUT', `MJAPI did not become healthy within ${maxWaitMs / 1000}s on port ${port}`);
|
|
879
|
+
}
|
|
880
|
+
// ─── Phase 2: Git Integration (@octokit/rest — pure HTTP, no shell-outs) ─
|
|
881
|
+
/**
|
|
882
|
+
* Git commit for a CodeGen cycle batch — commits all migration files,
|
|
883
|
+
* CodeGen outputs, and metadata from all requests in the batch.
|
|
884
|
+
* Returns the branch name on success.
|
|
885
|
+
*
|
|
886
|
+
* Uses Octokit GitHub API exclusively — no shell-outs, no git binary dependency.
|
|
887
|
+
* Works in any environment: local dev, Docker, Azure, CI/CD.
|
|
888
|
+
*/
|
|
889
|
+
async gitCommitAndPRForCycle(migrationFilePaths, affectedTables, description) {
|
|
890
|
+
return this.gitCommitAndPRViaAPI(migrationFilePaths, affectedTables, description);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Git commit and PR using Octokit GitHub API exclusively.
|
|
894
|
+
* For Docker/CI environments without a local `.git` directory.
|
|
895
|
+
*/
|
|
896
|
+
async gitCommitAndPRViaAPI(migrationFilePaths, affectedTables, description) {
|
|
897
|
+
const octokit = this.createOctokit();
|
|
898
|
+
const { owner, repo } = this.resolveGitHubOwnerRepo();
|
|
899
|
+
const branchName = this.generateBranchName(affectedTables);
|
|
900
|
+
const baseBranch = rsuConfig.GitTargetBranch;
|
|
901
|
+
this.rsuLog(`Octokit-only mode: creating branch "${branchName}" from "${baseBranch}"`);
|
|
902
|
+
const baseSha = await this.apiGetBranchSha(octokit, owner, repo, baseBranch);
|
|
903
|
+
await this.apiCreateBranch(octokit, owner, repo, branchName, baseSha);
|
|
904
|
+
const filesToCommit = await this.collectAllRSUFiles(migrationFilePaths);
|
|
905
|
+
this.rsuLog(`Collected ${filesToCommit.length} file(s) to commit via API`);
|
|
906
|
+
const treeSha = await this.apiCreateTreeFromFiles(octokit, owner, repo, baseSha, filesToCommit);
|
|
907
|
+
const tables = affectedTables.join(', ');
|
|
908
|
+
const commitMsg = `RSU: ${description}\n\nTables affected: ${tables}\nGenerated by Runtime Schema Update pipeline`;
|
|
909
|
+
const commitSha = await this.apiCreateCommit(octokit, owner, repo, commitMsg, treeSha, baseSha);
|
|
910
|
+
await this.apiUpdateBranchRef(octokit, owner, repo, branchName, commitSha);
|
|
911
|
+
await this.apiCreatePullRequest(octokit, owner, repo, branchName, baseBranch, migrationFilePaths, tables, description);
|
|
912
|
+
return branchName;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Resolve GitHub owner/repo from RSU_GITHUB_REPO env var or by parsing the git remote URL.
|
|
916
|
+
* In Octokit-only mode, RSU_GITHUB_REPO is required since there's no local git remote.
|
|
917
|
+
*/
|
|
918
|
+
resolveGitHubOwnerRepo() {
|
|
919
|
+
const envRepo = rsuConfig.GitHubRepo;
|
|
920
|
+
if (envRepo) {
|
|
921
|
+
const parts = envRepo.split('/');
|
|
922
|
+
if (parts.length === 2 && parts[0].length > 0 && parts[1].length > 0) {
|
|
923
|
+
return { owner: parts[0], repo: parts[1] };
|
|
924
|
+
}
|
|
925
|
+
throw new RSUError('CONFIG', `RSU_GITHUB_REPO must be in "owner/repo" format, got: "${envRepo}"`);
|
|
926
|
+
}
|
|
927
|
+
throw new RSUError('CONFIG', 'No local .git directory and RSU_GITHUB_REPO is not set. Set RSU_GITHUB_REPO=owner/repo for Octokit-only mode.');
|
|
928
|
+
}
|
|
929
|
+
/** Get the SHA of the tip of a branch via Octokit. */
|
|
930
|
+
async apiGetBranchSha(octokit, owner, repo, branch) {
|
|
931
|
+
const { data } = await octokit.git.getRef({ owner, repo, ref: `heads/${branch}` });
|
|
932
|
+
return data.object.sha;
|
|
933
|
+
}
|
|
934
|
+
/** Create a new branch ref via Octokit. */
|
|
935
|
+
async apiCreateBranch(octokit, owner, repo, branch, sha) {
|
|
936
|
+
await octokit.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha });
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Build a git tree from files via Octokit using inline content.
|
|
940
|
+
* This sends all files in a single createTree call instead of
|
|
941
|
+
* N+1 individual createBlob calls, avoiding GitHub's secondary rate limit.
|
|
942
|
+
*
|
|
943
|
+
* Files > 1 MB fall back to createBlob (GitHub rejects inline content above that).
|
|
944
|
+
*/
|
|
945
|
+
async apiCreateTreeFromFiles(octokit, owner, repo, baseTreeSha, files) {
|
|
946
|
+
const { readFileSync } = await import('node:fs');
|
|
947
|
+
const INLINE_LIMIT = 1_000_000; // 1 MB
|
|
948
|
+
const treeItems = [];
|
|
949
|
+
let blobCount = 0;
|
|
950
|
+
for (const file of files) {
|
|
951
|
+
const buf = readFileSync(file.absolutePath);
|
|
952
|
+
if (buf.length <= INLINE_LIMIT) {
|
|
953
|
+
treeItems.push({ path: file.repoPath, mode: '100644', type: 'blob', content: buf.toString('utf-8') });
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
// Large file — must create blob separately
|
|
957
|
+
const { data: blob } = await octokit.git.createBlob({
|
|
958
|
+
owner,
|
|
959
|
+
repo,
|
|
960
|
+
content: buf.toString('base64'),
|
|
961
|
+
encoding: 'base64',
|
|
962
|
+
});
|
|
963
|
+
treeItems.push({ path: file.repoPath, mode: '100644', type: 'blob', sha: blob.sha });
|
|
964
|
+
blobCount++;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
this.rsuLog(`Creating tree: ${treeItems.length} file(s) (${treeItems.length - blobCount} inline, ${blobCount} blob)`);
|
|
968
|
+
const { data: tree } = await octokit.git.createTree({
|
|
969
|
+
owner,
|
|
970
|
+
repo,
|
|
971
|
+
base_tree: baseTreeSha,
|
|
972
|
+
tree: treeItems,
|
|
973
|
+
});
|
|
974
|
+
return tree.sha;
|
|
975
|
+
}
|
|
976
|
+
/** Create a commit via Octokit. Returns the commit SHA. */
|
|
977
|
+
async apiCreateCommit(octokit, owner, repo, message, treeSha, parentSha) {
|
|
978
|
+
const commitParams = {
|
|
979
|
+
owner,
|
|
980
|
+
repo,
|
|
981
|
+
message,
|
|
982
|
+
tree: treeSha,
|
|
983
|
+
parents: [parentSha],
|
|
984
|
+
};
|
|
985
|
+
const userName = rsuConfig.GitUserName;
|
|
986
|
+
const userEmail = rsuConfig.GitUserEmail;
|
|
987
|
+
if (userName && userEmail) {
|
|
988
|
+
commitParams.author = { name: userName, email: userEmail, date: new Date().toISOString() };
|
|
989
|
+
}
|
|
990
|
+
const { data: commit } = await octokit.git.createCommit(commitParams);
|
|
991
|
+
return commit.sha;
|
|
992
|
+
}
|
|
993
|
+
/** Update a branch ref to point to a new commit SHA via Octokit. */
|
|
994
|
+
async apiUpdateBranchRef(octokit, owner, repo, branch, sha) {
|
|
995
|
+
await octokit.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha });
|
|
996
|
+
}
|
|
997
|
+
/** Create a PR via Octokit (API-only mode). */
|
|
998
|
+
async apiCreatePullRequest(octokit, owner, repo, head, base, migrationFilePaths, tables, description) {
|
|
999
|
+
const title = `RSU: ${description}`.substring(0, 70);
|
|
1000
|
+
const body = this.buildPRBody(tables, migrationFilePaths.length);
|
|
1001
|
+
await octokit.pulls.create({ owner, repo, title, body, base, head });
|
|
1002
|
+
}
|
|
1003
|
+
// ─── File Collection for API Mode ───────────────────────────────
|
|
1004
|
+
/**
|
|
1005
|
+
* Collect all RSU-produced files that should be committed.
|
|
1006
|
+
* Returns objects with both the absolute path (for reading) and
|
|
1007
|
+
* the repo-relative path (for the git tree).
|
|
1008
|
+
*/
|
|
1009
|
+
/** Check if a path is gitignored by asking git. */
|
|
1010
|
+
isGitIgnored(absPath, workDir) {
|
|
1011
|
+
const { execSync } = childProcess;
|
|
1012
|
+
try {
|
|
1013
|
+
execSync(`git check-ignore -q "${absPath}"`, { cwd: workDir, timeout: 5_000 });
|
|
1014
|
+
return true; // exit 0 = ignored
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
return false; // exit 1 = not ignored
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async collectAllRSUFiles(migrationFilePaths) {
|
|
1021
|
+
const { existsSync } = await import('node:fs');
|
|
1022
|
+
const { resolve } = await import('node:path');
|
|
1023
|
+
const workDir = resolve(rsuConfig.WorkDir);
|
|
1024
|
+
const files = [];
|
|
1025
|
+
// Migration files
|
|
1026
|
+
for (const filePath of migrationFilePaths) {
|
|
1027
|
+
if (filePath && existsSync(filePath)) {
|
|
1028
|
+
files.push({ repoPath: this.toRepoRelativePath(filePath, workDir), absolutePath: filePath });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// AdditionalSchemaInfo
|
|
1032
|
+
const schemaInfoPath = resolve(workDir, rsuConfig.AdditionalSchemaInfoPath);
|
|
1033
|
+
if (existsSync(schemaInfoPath)) {
|
|
1034
|
+
files.push({ repoPath: this.toRepoRelativePath(schemaInfoPath, workDir), absolutePath: schemaInfoPath });
|
|
1035
|
+
}
|
|
1036
|
+
// CodeGen output directories — skip gitignored dirs (SQL build artifacts, schema dumps)
|
|
1037
|
+
for (const outputPath of this._codeGenOutputPaths) {
|
|
1038
|
+
const absOutputPath = resolve(workDir, outputPath);
|
|
1039
|
+
if (!existsSync(absOutputPath))
|
|
1040
|
+
continue;
|
|
1041
|
+
if (this.isGitIgnored(absOutputPath, workDir)) {
|
|
1042
|
+
this.rsuLog(`Skipping gitignored CodeGen output: ${outputPath}`);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const collected = await this.collectFilesRecursively(absOutputPath);
|
|
1046
|
+
for (const absFile of collected) {
|
|
1047
|
+
files.push({ repoPath: this.toRepoRelativePath(absFile, workDir), absolutePath: absFile });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
this.rsuLog(`Collected ${files.length} file(s) to commit via API`);
|
|
1051
|
+
return files;
|
|
1052
|
+
}
|
|
1053
|
+
/** Convert an absolute path to a repo-relative path (forward slashes). */
|
|
1054
|
+
toRepoRelativePath(absolutePath, workDir) {
|
|
1055
|
+
const { resolve, relative } = nodePath;
|
|
1056
|
+
const resolved = resolve(absolutePath);
|
|
1057
|
+
return relative(workDir, resolved).replace(/\\/g, '/');
|
|
1058
|
+
}
|
|
1059
|
+
/** Recursively collect all files in a directory. */
|
|
1060
|
+
async collectFilesRecursively(dirPath) {
|
|
1061
|
+
const { readdirSync, statSync } = await import('node:fs');
|
|
1062
|
+
const { join } = await import('node:path');
|
|
1063
|
+
const results = [];
|
|
1064
|
+
const entries = readdirSync(dirPath);
|
|
1065
|
+
for (const entry of entries) {
|
|
1066
|
+
const fullPath = join(dirPath, entry);
|
|
1067
|
+
const stat = statSync(fullPath);
|
|
1068
|
+
if (stat.isDirectory()) {
|
|
1069
|
+
const nested = await this.collectFilesRecursively(fullPath);
|
|
1070
|
+
results.push(...nested);
|
|
1071
|
+
}
|
|
1072
|
+
else if (stat.isFile()) {
|
|
1073
|
+
results.push(fullPath);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return results;
|
|
1077
|
+
}
|
|
1078
|
+
/** Build the PR body markdown. */
|
|
1079
|
+
buildPRBody(tables, migrationFileCount) {
|
|
1080
|
+
return [
|
|
1081
|
+
'## Runtime Schema Update',
|
|
1082
|
+
'',
|
|
1083
|
+
`**Tables affected:** ${tables}`,
|
|
1084
|
+
`**Migration files:** ${migrationFileCount}`,
|
|
1085
|
+
'',
|
|
1086
|
+
'### What changed',
|
|
1087
|
+
'- Migration SQL executed against the database',
|
|
1088
|
+
'- CodeGen re-generated entity metadata, TypeScript classes, and SQL objects',
|
|
1089
|
+
'- MJAPI restarted with updated schema',
|
|
1090
|
+
'',
|
|
1091
|
+
'### Generated by',
|
|
1092
|
+
'Runtime Schema Update (RSU) pipeline — auto-generated PR.',
|
|
1093
|
+
].join('\n');
|
|
1094
|
+
}
|
|
1095
|
+
/** Create an authenticated Octokit instance from environment token. */
|
|
1096
|
+
createOctokit() {
|
|
1097
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
1098
|
+
if (!token) {
|
|
1099
|
+
throw new RSUError('GIT', 'GitHub token not found. Set GITHUB_TOKEN or GH_TOKEN environment variable for PR creation.');
|
|
1100
|
+
}
|
|
1101
|
+
return new Octokit({ auth: token });
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Generate a branch name from affected table names.
|
|
1105
|
+
* Format: rsu/{YYYYMMDDHHmm}-{table-slugs}
|
|
1106
|
+
*/
|
|
1107
|
+
generateBranchName(affectedTables) {
|
|
1108
|
+
const timestamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12);
|
|
1109
|
+
const tableSlug = affectedTables
|
|
1110
|
+
.slice(0, 3)
|
|
1111
|
+
.map((t) => t.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase())
|
|
1112
|
+
.join('-');
|
|
1113
|
+
return `rsu/${timestamp}-${tableSlug}`;
|
|
1114
|
+
}
|
|
1115
|
+
// ─── DB-Backed Mutex (Multi-Instance Safety) ──────────────────
|
|
1116
|
+
/** Whether the DB-backed lock is enabled via RSU_DB_LOCK_ENABLED=1. */
|
|
1117
|
+
get IsDBLockEnabled() {
|
|
1118
|
+
return rsuConfig.IsDBLockEnabled;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Try to acquire a DB-backed lock by inserting a row into the RSULock table.
|
|
1122
|
+
* Uses SQLDialect for DDL generation and DBExecProvider for CLI execution.
|
|
1123
|
+
* Returns true if the lock was acquired, false if another instance holds it.
|
|
1124
|
+
*/
|
|
1125
|
+
async acquireDBLock() {
|
|
1126
|
+
try {
|
|
1127
|
+
const defaultSchema = rsuConfig.DefaultSchema;
|
|
1128
|
+
const lockId = `rsu-${process.pid}-${Date.now()}`;
|
|
1129
|
+
this._dbLockId = lockId;
|
|
1130
|
+
const sql = this.buildAcquireLockSQL(defaultSchema, lockId);
|
|
1131
|
+
await this.getDBProvider().ExecuteSQL(sql, undefined, { isMutation: true, description: 'RSU lock acquire' });
|
|
1132
|
+
// If no exception, lock was acquired (signal would have been a RAISERROR that throws)
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
catch (err) {
|
|
1136
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1137
|
+
// Check if the error is our lock-held signal
|
|
1138
|
+
if (msg.includes(RSU_LOCK_HELD_SIGNAL)) {
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
// Other errors — fall through to in-memory only
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Release the DB-backed lock by deleting the lock row.
|
|
1147
|
+
*/
|
|
1148
|
+
async releaseDBLock() {
|
|
1149
|
+
if (!this._dbLockId)
|
|
1150
|
+
return;
|
|
1151
|
+
try {
|
|
1152
|
+
const d = this.Dialect;
|
|
1153
|
+
const defaultSchema = rsuConfig.DefaultSchema;
|
|
1154
|
+
const quotedTable = d.QuoteSchema(defaultSchema, 'RSULock');
|
|
1155
|
+
const sql = `DELETE FROM ${quotedTable} WHERE LockID = '${this._dbLockId}';`;
|
|
1156
|
+
await this.getDBProvider().ExecuteSQL(sql, undefined, { isMutation: true, description: 'RSU lock release' });
|
|
1157
|
+
this._dbLockId = null;
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
/* best-effort release */
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
// ─── Audit Logging ──────────────────────────────────────────────
|
|
1164
|
+
/**
|
|
1165
|
+
* Write an audit log entry for a pipeline run to the RSUAuditLog table.
|
|
1166
|
+
*
|
|
1167
|
+
* This is called automatically at the end of RunPipeline() if the DB
|
|
1168
|
+
* is available and RSU_AUDIT_LOG_ENABLED is not explicitly '0'.
|
|
1169
|
+
*/
|
|
1170
|
+
async writeAuditLog(input, result) {
|
|
1171
|
+
if (!rsuConfig.IsAuditLogEnabled)
|
|
1172
|
+
return;
|
|
1173
|
+
try {
|
|
1174
|
+
const defaultSchema = rsuConfig.DefaultSchema;
|
|
1175
|
+
// Ensure audit table exists (no user input, safe as string DDL)
|
|
1176
|
+
const createTableSQL = this.buildAuditTableDDL(defaultSchema);
|
|
1177
|
+
await this.getDBProvider().ExecuteSQL(createTableSQL, undefined, { isMutation: true, description: 'RSU audit table DDL' });
|
|
1178
|
+
// Parameterized INSERT for all string values
|
|
1179
|
+
await this.writeAuditInsert(defaultSchema, input, result);
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
/* Audit logging is best-effort — don't fail the pipeline */
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
// ─── Platform Abstraction ────────────────────────────────────────
|
|
1186
|
+
/** Resolve the database platform from environment configuration. */
|
|
1187
|
+
get Platform() {
|
|
1188
|
+
const platform = (process.env.DB_PLATFORM || 'sqlserver').toLowerCase();
|
|
1189
|
+
if (platform !== 'sqlserver' && platform !== 'postgresql') {
|
|
1190
|
+
throw new RSUError('CONFIG', `Unsupported DB_PLATFORM: "${platform}". Must be "sqlserver" or "postgresql".`);
|
|
1191
|
+
}
|
|
1192
|
+
return platform;
|
|
1193
|
+
}
|
|
1194
|
+
/** Get the SQLDialect for the configured platform (SQL generation). */
|
|
1195
|
+
get Dialect() {
|
|
1196
|
+
return GetDialect(this.Platform);
|
|
1197
|
+
}
|
|
1198
|
+
/** Get the database provider for DDL operations. Prefers the dedicated DDL provider if set. */
|
|
1199
|
+
getDBProvider() {
|
|
1200
|
+
if (this._ddlProvider) {
|
|
1201
|
+
return this._ddlProvider;
|
|
1202
|
+
}
|
|
1203
|
+
const provider = Metadata.Provider;
|
|
1204
|
+
if (!provider || !('ExecuteSQL' in provider)) {
|
|
1205
|
+
throw new RSUError('CONFIG', 'MJ data provider is not initialized. RSU requires a running MJAPI with a configured database provider.');
|
|
1206
|
+
}
|
|
1207
|
+
return provider;
|
|
1208
|
+
}
|
|
1209
|
+
// ─── DDL Generation (dialect-driven, zero platform checks) ──────
|
|
1210
|
+
/**
|
|
1211
|
+
* Generate SQL to create the RSULock table (if not exists), clean expired locks,
|
|
1212
|
+
* and attempt to acquire the lock.
|
|
1213
|
+
*/
|
|
1214
|
+
buildAcquireLockSQL(schema, lockId) {
|
|
1215
|
+
const d = this.Dialect;
|
|
1216
|
+
const quotedTable = d.QuoteSchema(schema, 'RSULock');
|
|
1217
|
+
const utcNow = d.CurrentTimestampUTC();
|
|
1218
|
+
const varchar200 = d.MapDataTypeToString('NVARCHAR', 200);
|
|
1219
|
+
const timestampType = d.MapDataTypeToString('DATETIMEOFFSET');
|
|
1220
|
+
const expiryDefault = d.DateAddExpression('MINUTE', 30, utcNow);
|
|
1221
|
+
const columnsDDL = [
|
|
1222
|
+
` LockID ${varchar200} NOT NULL,`,
|
|
1223
|
+
` AcquiredAt ${timestampType} NOT NULL DEFAULT ${utcNow},`,
|
|
1224
|
+
` ExpiresAt ${timestampType} NOT NULL DEFAULT ${expiryDefault},`,
|
|
1225
|
+
` CONSTRAINT PK_RSULock PRIMARY KEY (LockID)`,
|
|
1226
|
+
].join('\n');
|
|
1227
|
+
const createTable = d.CreateTableIfNotExistsDDL(schema, 'RSULock', columnsDDL);
|
|
1228
|
+
const cleanExpired = `DELETE FROM ${quotedTable} WHERE ExpiresAt < ${utcNow};`;
|
|
1229
|
+
const acquireLock = d.ConditionalBlock(`(SELECT COUNT(*) FROM ${quotedTable}) = 0`, `INSERT INTO ${quotedTable} (LockID) VALUES ('${lockId}')`, d.RaiseSignalSQL(RSU_LOCK_HELD_SIGNAL));
|
|
1230
|
+
return [createTable, '-- Clean up expired locks', cleanExpired, '-- Try to acquire lock', acquireLock].join('\n');
|
|
1231
|
+
}
|
|
1232
|
+
/** Generate SQL to create the RSUAuditLog table if it doesn't exist. */
|
|
1233
|
+
buildAuditTableDDL(schema) {
|
|
1234
|
+
const d = this.Dialect;
|
|
1235
|
+
const intType = d.MapDataTypeToString('INT');
|
|
1236
|
+
const autoIncrement = d.AutoIncrementPKExpression();
|
|
1237
|
+
const varchar500 = d.MapDataTypeToString('NVARCHAR', 500);
|
|
1238
|
+
const varchar200 = d.MapDataTypeToString('NVARCHAR', 200);
|
|
1239
|
+
const varchar100 = d.MapDataTypeToString('NVARCHAR', 100);
|
|
1240
|
+
const textType = d.MapDataTypeToString('NVARCHAR', -1);
|
|
1241
|
+
const boolType = d.MapDataTypeToString('BIT');
|
|
1242
|
+
const timestampType = d.MapDataTypeToString('DATETIMEOFFSET');
|
|
1243
|
+
const utcNow = d.CurrentTimestampUTC();
|
|
1244
|
+
const boolDefault0 = d.BooleanLiteral(false);
|
|
1245
|
+
const columnsDDL = [
|
|
1246
|
+
` ID ${intType} ${autoIncrement} NOT NULL,`,
|
|
1247
|
+
` Description ${varchar500} NOT NULL,`,
|
|
1248
|
+
` AffectedTables ${textType} NULL,`,
|
|
1249
|
+
` Success ${boolType} NOT NULL,`,
|
|
1250
|
+
` APIRestarted ${boolType} NOT NULL DEFAULT ${boolDefault0},`,
|
|
1251
|
+
` GitCommitSuccess ${boolType} NOT NULL DEFAULT ${boolDefault0},`,
|
|
1252
|
+
` BranchName ${varchar200} NULL,`,
|
|
1253
|
+
` MigrationFilePath ${varchar500} NULL,`,
|
|
1254
|
+
` ErrorMessage ${textType} NULL,`,
|
|
1255
|
+
` ErrorStep ${varchar100} NULL,`,
|
|
1256
|
+
` StepsJSON ${textType} NULL,`,
|
|
1257
|
+
` TotalDurationMs ${intType} NULL,`,
|
|
1258
|
+
` RunAt ${timestampType} NOT NULL DEFAULT ${utcNow},`,
|
|
1259
|
+
` CONSTRAINT PK_RSUAuditLog PRIMARY KEY (ID)`,
|
|
1260
|
+
].join('\n');
|
|
1261
|
+
return d.CreateTableIfNotExistsDDL(schema, 'RSUAuditLog', columnsDDL);
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Write the audit log INSERT using parameterized query to prevent SQL injection.
|
|
1265
|
+
* The CREATE TABLE DDL runs separately (no user input, safe as-is).
|
|
1266
|
+
*/
|
|
1267
|
+
async writeAuditInsert(schema, input, result) {
|
|
1268
|
+
const d = this.Dialect;
|
|
1269
|
+
const quotedTable = d.QuoteSchema(schema, 'RSUAuditLog');
|
|
1270
|
+
const totalMs = result.Steps.reduce((sum, s) => sum + s.DurationMs, 0);
|
|
1271
|
+
const stepsJson = JSON.stringify(result.Steps).substring(0, 8000);
|
|
1272
|
+
const boolTrue = d.BooleanLiteral(true);
|
|
1273
|
+
const boolFalse = d.BooleanLiteral(false);
|
|
1274
|
+
// Use parameterized query for all string values to prevent injection
|
|
1275
|
+
const p = (i) => d.ParameterPlaceholder(i);
|
|
1276
|
+
const sql = [
|
|
1277
|
+
`INSERT INTO ${quotedTable}`,
|
|
1278
|
+
` (Description, AffectedTables, Success, APIRestarted, GitCommitSuccess, BranchName, MigrationFilePath, ErrorMessage, ErrorStep, StepsJSON, TotalDurationMs)`,
|
|
1279
|
+
`VALUES (`,
|
|
1280
|
+
` ${p(0)}, ${p(1)},`,
|
|
1281
|
+
` ${result.Success ? boolTrue : boolFalse},`,
|
|
1282
|
+
` ${result.APIRestarted ? boolTrue : boolFalse},`,
|
|
1283
|
+
` ${result.GitCommitSuccess ? boolTrue : boolFalse},`,
|
|
1284
|
+
` ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)}, ${p(6)},`,
|
|
1285
|
+
` ${totalMs}`,
|
|
1286
|
+
`);`,
|
|
1287
|
+
].join('\n');
|
|
1288
|
+
const params = [
|
|
1289
|
+
input.Description,
|
|
1290
|
+
input.AffectedTables.join(', '),
|
|
1291
|
+
result.BranchName ?? null,
|
|
1292
|
+
result.MigrationFilePath ?? null,
|
|
1293
|
+
result.ErrorMessage?.substring(0, 4000) ?? null,
|
|
1294
|
+
result.ErrorStep ?? null,
|
|
1295
|
+
stepsJson,
|
|
1296
|
+
];
|
|
1297
|
+
await this.getDBProvider().ExecuteSQL(sql, params, { isMutation: true, description: 'RSU audit log insert' });
|
|
1298
|
+
}
|
|
1299
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
1300
|
+
/** Build a failed RSUPipelineResult for early-exit scenarios (validation, lock). */
|
|
1301
|
+
buildFailedResult(_input, failedStep, steps) {
|
|
1302
|
+
const failMsg = steps.find((s) => s.Name === failedStep && s.Status === 'failed')?.Message ?? `Failed at ${failedStep}`;
|
|
1303
|
+
return {
|
|
1304
|
+
Success: false,
|
|
1305
|
+
APIRestarted: false,
|
|
1306
|
+
GitCommitSuccess: false,
|
|
1307
|
+
Steps: [...steps],
|
|
1308
|
+
ErrorMessage: failMsg,
|
|
1309
|
+
ErrorStep: failedStep,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
buildBatchResult(results) {
|
|
1313
|
+
const successCount = results.filter((r) => r.Success).length;
|
|
1314
|
+
const failureCount = results.length - successCount;
|
|
1315
|
+
this.rsuLog(`Pipeline batch finished — ${successCount} succeeded, ${failureCount} failed, ${results.length} total`);
|
|
1316
|
+
for (const r of results) {
|
|
1317
|
+
const status = r.Success ? '✓' : '✗';
|
|
1318
|
+
this.rsuLog(` ${status} ${r.MigrationFilePath ?? '(no file)'} — ${r.Steps.map((s) => `${s.Name}:${s.Status}`).join(', ')}`);
|
|
1319
|
+
}
|
|
1320
|
+
this.rsuLog(`═══════════════════════════════════════════════════`);
|
|
1321
|
+
return {
|
|
1322
|
+
Results: results,
|
|
1323
|
+
SuccessCount: successCount,
|
|
1324
|
+
FailureCount: failureCount,
|
|
1325
|
+
TotalCount: results.length,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
getProtectedSchemas() {
|
|
1329
|
+
return rsuConfig.ProtectedSchemas;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Helper to execute shell commands asynchronously.
|
|
1333
|
+
*/
|
|
1334
|
+
async getExecAsync() {
|
|
1335
|
+
const { exec } = await import('node:child_process');
|
|
1336
|
+
const { promisify } = await import('node:util');
|
|
1337
|
+
const execAsync = promisify(exec);
|
|
1338
|
+
return {
|
|
1339
|
+
execAsync: (cmd, opts) => execAsync(cmd, {
|
|
1340
|
+
timeout: opts?.timeout ?? 60_000,
|
|
1341
|
+
maxBuffer: opts?.maxBuffer ?? 50 * 1024 * 1024, // 50 MB (CodeGen is verbose)
|
|
1342
|
+
}),
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Run a named pipeline step, capturing timing and errors.
|
|
1347
|
+
* Returns the step's return value on success, or undefined on failure.
|
|
1348
|
+
*/
|
|
1349
|
+
async runStep(name, fn, steps) {
|
|
1350
|
+
const start = Date.now();
|
|
1351
|
+
this.rsuLog(`▶ Starting step: ${name}`);
|
|
1352
|
+
try {
|
|
1353
|
+
const result = await fn();
|
|
1354
|
+
const durationMs = Date.now() - start;
|
|
1355
|
+
const msg = `${name} completed successfully`;
|
|
1356
|
+
steps.push({ Name: name, Status: 'success', DurationMs: durationMs, Message: msg });
|
|
1357
|
+
this.rsuLog(`✓ ${name} — ${durationMs}ms`);
|
|
1358
|
+
return result;
|
|
1359
|
+
}
|
|
1360
|
+
catch (error) {
|
|
1361
|
+
const durationMs = Date.now() - start;
|
|
1362
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1363
|
+
steps.push({ Name: name, Status: 'failed', DurationMs: durationMs, Message: msg });
|
|
1364
|
+
this.rsuLog(`✗ ${name} — FAILED after ${durationMs}ms: ${msg}`);
|
|
1365
|
+
return undefined;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Log to both console and file. File path: RSU_WORK_DIR/rsu-pipeline.log.
|
|
1370
|
+
*/
|
|
1371
|
+
rsuLog(message) {
|
|
1372
|
+
const timestamp = new Date().toISOString();
|
|
1373
|
+
const line = `[RSU] ${timestamp} ${message}`;
|
|
1374
|
+
console.log(line);
|
|
1375
|
+
try {
|
|
1376
|
+
const logPath = `${rsuConfig.WorkDir}/rsu-pipeline.log`;
|
|
1377
|
+
appendFileSync(logPath, `${line}\n`, 'utf-8');
|
|
1378
|
+
}
|
|
1379
|
+
catch {
|
|
1380
|
+
// Best-effort file logging — don't break the pipeline
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
// ─── Custom Error ────────────────────────────────────────────────────
|
|
1385
|
+
/**
|
|
1386
|
+
* RSU-specific error with a step code for structured error handling.
|
|
1387
|
+
*/
|
|
1388
|
+
export class RSUError extends Error {
|
|
1389
|
+
constructor(code, message) {
|
|
1390
|
+
super(message);
|
|
1391
|
+
this.name = 'RSUError';
|
|
1392
|
+
this.Code = code;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
//# sourceMappingURL=RuntimeSchemaManager.js.map
|