@memberjunction/schema-engine 0.0.1 → 5.17.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.
Files changed (46) hide show
  1. package/dist/DDLGenerator.d.ts +48 -0
  2. package/dist/DDLGenerator.d.ts.map +1 -0
  3. package/dist/DDLGenerator.js +159 -0
  4. package/dist/DDLGenerator.js.map +1 -0
  5. package/dist/MigrationFileWriter.d.ts +32 -0
  6. package/dist/MigrationFileWriter.d.ts.map +1 -0
  7. package/dist/MigrationFileWriter.js +69 -0
  8. package/dist/MigrationFileWriter.js.map +1 -0
  9. package/dist/RSUMetrics.d.ts +48 -0
  10. package/dist/RSUMetrics.d.ts.map +1 -0
  11. package/dist/RSUMetrics.js +102 -0
  12. package/dist/RSUMetrics.js.map +1 -0
  13. package/dist/RuntimeSchemaManager.d.ts +454 -0
  14. package/dist/RuntimeSchemaManager.d.ts.map +1 -0
  15. package/dist/RuntimeSchemaManager.js +1395 -0
  16. package/dist/RuntimeSchemaManager.js.map +1 -0
  17. package/dist/SchemaEngine.d.ts +51 -0
  18. package/dist/SchemaEngine.d.ts.map +1 -0
  19. package/dist/SchemaEngine.js +100 -0
  20. package/dist/SchemaEngine.js.map +1 -0
  21. package/dist/SchemaEvolution.d.ts +30 -0
  22. package/dist/SchemaEvolution.d.ts.map +1 -0
  23. package/dist/SchemaEvolution.js +95 -0
  24. package/dist/SchemaEvolution.js.map +1 -0
  25. package/dist/SchemaValidator.d.ts +22 -0
  26. package/dist/SchemaValidator.d.ts.map +1 -0
  27. package/dist/SchemaValidator.js +93 -0
  28. package/dist/SchemaValidator.js.map +1 -0
  29. package/dist/SoftFKConfigEmitter.d.ts +59 -0
  30. package/dist/SoftFKConfigEmitter.d.ts.map +1 -0
  31. package/dist/SoftFKConfigEmitter.js +87 -0
  32. package/dist/SoftFKConfigEmitter.js.map +1 -0
  33. package/dist/index.d.ts +21 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +20 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/interfaces.d.ts +175 -0
  38. package/dist/interfaces.d.ts.map +1 -0
  39. package/dist/interfaces.js +9 -0
  40. package/dist/interfaces.js.map +1 -0
  41. package/dist/utils.d.ts +8 -0
  42. package/dist/utils.d.ts.map +1 -0
  43. package/dist/utils.js +16 -0
  44. package/dist/utils.js.map +1 -0
  45. package/package.json +31 -7
  46. 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