@memberjunction/schema-engine 6.1.0-edge.2 → 6.1.0-edge.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +180 -4
- package/dist/DDLGenerator.d.ts +1 -1
- package/dist/DDLGenerator.d.ts.map +1 -1
- package/dist/DDLGenerator.js +1 -1
- package/dist/DDLGenerator.js.map +1 -1
- package/dist/RuntimeSchemaManager.d.ts +195 -7
- package/dist/RuntimeSchemaManager.d.ts.map +1 -1
- package/dist/RuntimeSchemaManager.js +276 -49
- package/dist/RuntimeSchemaManager.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/utils.d.ts +3 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +3 -5
- package/dist/utils.js.map +1 -1
- package/package.json +7 -6
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* In-memory concurrency mutex (one operation at a time).
|
|
10
10
|
*/
|
|
11
11
|
import { BaseSingleton } from '@memberjunction/global';
|
|
12
|
-
import { LogError, Metadata } from '@memberjunction/core';
|
|
12
|
+
import { LogError, Metadata, RunView, } from '@memberjunction/core';
|
|
13
13
|
import { Octokit } from '@octokit/rest';
|
|
14
14
|
import { RSUMetrics } from './RSUMetrics.js';
|
|
15
15
|
import { GetDialect } from './DDLGenerator.js';
|
|
@@ -80,9 +80,6 @@ class RSUConfig {
|
|
|
80
80
|
get IsAuditLogEnabled() {
|
|
81
81
|
return process.env.RSU_AUDIT_LOG_ENABLED !== '0';
|
|
82
82
|
}
|
|
83
|
-
get PendingWorkPath() {
|
|
84
|
-
return process.env.RSU_PENDING_WORK_PATH || '.rsu_pending';
|
|
85
|
-
}
|
|
86
83
|
get ProtectedSchemas() {
|
|
87
84
|
const envSchemas = process.env.RSU_PROTECTED_SCHEMAS;
|
|
88
85
|
return envSchemas ? envSchemas.split(',').map((s) => s.trim()) : [];
|
|
@@ -145,9 +142,20 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
145
142
|
this._outOfSyncSince = null;
|
|
146
143
|
this._lastRunAt = null;
|
|
147
144
|
this._lastRunResult = null;
|
|
145
|
+
/** Names of deprecated members already warned about, so a hot path logs once, not per call. */
|
|
146
|
+
this.warnedDeprecations = new Set();
|
|
148
147
|
this._currentStepName = null;
|
|
149
148
|
this._currentStepIndex = null;
|
|
150
149
|
this._stepTotal = null;
|
|
150
|
+
// ── Pipeline observer ──────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Optional observer notified of every step and run boundary. Set once at process startup (see
|
|
153
|
+
* the RSU progress bridge in MJServer) — a single observer is sufficient because RSU runs are
|
|
154
|
+
* serialized by the pipeline lock, so events can never interleave between runs.
|
|
155
|
+
*
|
|
156
|
+
* Never awaited, never allowed to fail the pipeline. See {@link RSUPipelineObserver}.
|
|
157
|
+
*/
|
|
158
|
+
this.PipelineObserver = null;
|
|
151
159
|
/** Waiters queued behind the current lock holder. */
|
|
152
160
|
this._lockWaiters = [];
|
|
153
161
|
this._dbLockId = null;
|
|
@@ -202,41 +210,164 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
202
210
|
return this._additionalSchemaInfoPath ?? rsuConfig.AdditionalSchemaInfoPath;
|
|
203
211
|
}
|
|
204
212
|
// ─── Pending Work (post-restart tasks) ─────────────────────────
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Register pending work durably in `MJ: RSU Pending Works` so it survives the
|
|
215
|
+
* post-migration restart. Returns the new row's ID.
|
|
216
|
+
*
|
|
217
|
+
* Unlike the `.rsu_pending` files this replaces, the row is NOT consumed on read —
|
|
218
|
+
* it stays Pending until the consumer explicitly calls {@link CompletePendingWork}
|
|
219
|
+
* or {@link FailPendingWork}, so a crash mid-consumption leaves visible, resumable work.
|
|
220
|
+
*/
|
|
221
|
+
async WritePendingWork(data, contextUser, provider) {
|
|
222
|
+
// `contextUser` is optional only to keep the published 1-arg signature compiling
|
|
223
|
+
// (see the deprecation note above the class). A durable row cannot be written
|
|
224
|
+
// without a user, and silently pretending to have persisted the work would defeat
|
|
225
|
+
// the entire point of this queue — so the 1-arg form fails loudly instead.
|
|
226
|
+
if (!contextUser) {
|
|
227
|
+
throw new Error('RuntimeSchemaManager.WritePendingWork now persists to `MJ: RSU Pending Works` and requires a contextUser. ' +
|
|
228
|
+
'Call WritePendingWork(data, contextUser) — the previous 1-argument form wrote a .rsu_pending file and no longer exists.');
|
|
229
|
+
}
|
|
230
|
+
const md = provider ?? new Metadata();
|
|
231
|
+
const row = await md.GetEntityObject('MJ: RSU Pending Works', contextUser);
|
|
232
|
+
row.NewRecord();
|
|
233
|
+
row.CompanyIntegrationID = data.CompanyIntegrationID;
|
|
234
|
+
row.PayloadJSON = JSON.stringify(data);
|
|
235
|
+
row.Status = 'Pending';
|
|
236
|
+
if (!(await row.Save())) {
|
|
237
|
+
throw new Error(`Failed to register RSU pending work: ${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
238
|
+
}
|
|
239
|
+
this.rsuLog(`Registered pending work ${row.ID} for company integration ${data.CompanyIntegrationID}`);
|
|
240
|
+
return row.ID;
|
|
215
241
|
}
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Read every still-Pending work row. Never deletes or mutates — the caller marks each
|
|
244
|
+
* row Completed/Failed once it has actually finished processing it. Rows whose payload
|
|
245
|
+
* fails to parse are reported as Failed so they stop being retried forever.
|
|
246
|
+
*
|
|
247
|
+
* @param staleAfterMinutes when set, rows created more than this many minutes ago are
|
|
248
|
+
* logged as stale so an operator can see work that a previous process abandoned.
|
|
249
|
+
*/
|
|
250
|
+
async ReadPendingWork(contextUser, staleAfterMinutes, provider) {
|
|
251
|
+
// Read through the SAME provider the caller writes through. Defaulting to the global
|
|
252
|
+
// provider here would read a different connection's rows than WritePendingWork just
|
|
253
|
+
// wrote, which is the whole reason the provider is threaded through these methods.
|
|
254
|
+
const rv = new RunView(provider);
|
|
255
|
+
const result = await rv.RunView({
|
|
256
|
+
EntityName: 'MJ: RSU Pending Works',
|
|
257
|
+
ExtraFilter: `Status = 'Pending'`,
|
|
258
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
259
|
+
ResultType: 'entity_object',
|
|
260
|
+
BypassCache: true,
|
|
261
|
+
}, contextUser);
|
|
262
|
+
if (!result.Success) {
|
|
263
|
+
LogError(`[RSU] Failed to read pending work: ${result.ErrorMessage}`);
|
|
222
264
|
return [];
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
for (const
|
|
226
|
-
const
|
|
265
|
+
}
|
|
266
|
+
const items = [];
|
|
267
|
+
for (const row of result.Results) {
|
|
268
|
+
const createdAt = row.__mj_CreatedAt ?? new Date();
|
|
227
269
|
try {
|
|
228
|
-
|
|
229
|
-
results.push(data);
|
|
230
|
-
unlinkSync(filePath);
|
|
270
|
+
items.push({ ID: row.ID, Work: JSON.parse(row.PayloadJSON), CreatedAt: createdAt });
|
|
231
271
|
}
|
|
232
|
-
catch {
|
|
233
|
-
|
|
272
|
+
catch (err) {
|
|
273
|
+
await this.FailPendingWork(row.ID, `Corrupt PayloadJSON: ${err instanceof Error ? err.message : String(err)}`, contextUser, provider);
|
|
234
274
|
}
|
|
235
275
|
}
|
|
236
|
-
if (
|
|
237
|
-
|
|
276
|
+
if (staleAfterMinutes !== undefined) {
|
|
277
|
+
const cutoff = Date.now() - staleAfterMinutes * 60_000;
|
|
278
|
+
const stale = items.filter((i) => i.CreatedAt.getTime() < cutoff);
|
|
279
|
+
if (stale.length > 0) {
|
|
280
|
+
LogError(`[RSU] ${stale.length} pending work item(s) older than ${staleAfterMinutes} minute(s) are still unprocessed: ${stale.map((s) => s.ID).join(', ')}`);
|
|
281
|
+
}
|
|
238
282
|
}
|
|
239
|
-
|
|
283
|
+
if (items.length > 0)
|
|
284
|
+
this.rsuLog(`Found ${items.length} pending work item(s)`);
|
|
285
|
+
return items;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* @deprecated Use {@link ReadPendingWork} instead, and mark each item terminal with
|
|
289
|
+
* {@link CompletePendingWork} / {@link FailPendingWork} once you have processed it.
|
|
290
|
+
*
|
|
291
|
+
* Retained with its original signature so consumers taking this minor upgrade still
|
|
292
|
+
* compile. It is NO LONGER FUNCTIONAL: pending work lives in `MJ: RSU Pending Works`
|
|
293
|
+
* rather than in `.rsu_pending` files, and reading it requires a context user this
|
|
294
|
+
* signature has no way to supply. Returns an empty array — the value that previously
|
|
295
|
+
* meant "no pending work" — rather than pretending to have drained a queue.
|
|
296
|
+
*/
|
|
297
|
+
async ReadAndClearPendingWork() {
|
|
298
|
+
this.warnDeprecatedOnce('ReadAndClearPendingWork', 'RuntimeSchemaManager.ReadAndClearPendingWork() is deprecated and no longer functional. ' +
|
|
299
|
+
'Pending work is now durable in `MJ: RSU Pending Works`; call ReadPendingWork(contextUser) and then ' +
|
|
300
|
+
'CompletePendingWork/FailPendingWork per item. Returning an empty array.');
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
warnDeprecatedOnce(member, message) {
|
|
304
|
+
if (this.warnedDeprecations.has(member))
|
|
305
|
+
return;
|
|
306
|
+
this.warnedDeprecations.add(member);
|
|
307
|
+
LogError(`[RSU] ${message}`);
|
|
308
|
+
}
|
|
309
|
+
/** Mark a pending work row Completed. Call ONLY after the work actually succeeded. */
|
|
310
|
+
async CompletePendingWork(id, contextUser, provider) {
|
|
311
|
+
return this.setPendingWorkTerminalStatus(id, 'Completed', undefined, contextUser, provider);
|
|
312
|
+
}
|
|
313
|
+
/** Mark a pending work row Failed, recording why. */
|
|
314
|
+
async FailPendingWork(id, errorMessage, contextUser, provider) {
|
|
315
|
+
return this.setPendingWorkTerminalStatus(id, 'Failed', errorMessage, contextUser, provider);
|
|
316
|
+
}
|
|
317
|
+
async setPendingWorkTerminalStatus(id, status, errorMessage, contextUser, provider) {
|
|
318
|
+
const md = provider ?? new Metadata();
|
|
319
|
+
const row = await md.GetEntityObject('MJ: RSU Pending Works', contextUser);
|
|
320
|
+
if (!(await row.Load(id))) {
|
|
321
|
+
LogError(`[RSU] Pending work ${id} not found when marking ${status}`);
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
row.Status = status;
|
|
325
|
+
row.ErrorMessage = errorMessage ?? null;
|
|
326
|
+
row.ProcessedAt = new Date();
|
|
327
|
+
if (!(await row.Save())) {
|
|
328
|
+
LogError(`[RSU] Failed to mark pending work ${id} as ${status}: ${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Register every input's PendingWork rows. Called for successful migrations only,
|
|
335
|
+
* before the restart. Returns row IDs keyed by the input's index within `inputs`.
|
|
336
|
+
*
|
|
337
|
+
* Registration failures are collected, NOT swallowed. This step is the durability
|
|
338
|
+
* boundary: once the restart fires, work that never became a row is gone, and the
|
|
339
|
+
* post-restart consumer has nothing to find. A run whose rows failed to register
|
|
340
|
+
* therefore reports the failure on its result instead of appearing to have succeeded.
|
|
341
|
+
*/
|
|
342
|
+
async registerPendingWork(inputs) {
|
|
343
|
+
const registered = new Map();
|
|
344
|
+
const errors = new Map();
|
|
345
|
+
const addError = (input, message) => {
|
|
346
|
+
LogError(`[RSU] ${message}`);
|
|
347
|
+
const list = errors.get(input) ?? [];
|
|
348
|
+
list.push(message);
|
|
349
|
+
errors.set(input, list);
|
|
350
|
+
};
|
|
351
|
+
for (const input of inputs) {
|
|
352
|
+
if (!input.PendingWork?.length)
|
|
353
|
+
continue;
|
|
354
|
+
if (!input.ContextUser) {
|
|
355
|
+
addError(input, `PendingWork supplied without ContextUser for "${input.Description}" — nothing was registered, so this work will NOT run after the restart`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const ids = [];
|
|
359
|
+
for (const work of input.PendingWork) {
|
|
360
|
+
try {
|
|
361
|
+
ids.push(await this.WritePendingWork(work, input.ContextUser));
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
addError(input, `Failed to register post-restart work for company integration ${work.CompanyIntegrationID}: ` +
|
|
365
|
+
`${err instanceof Error ? err.message : String(err)} — this work will NOT run after the restart`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
registered.set(input, ids);
|
|
369
|
+
}
|
|
370
|
+
return { IDs: registered, Errors: errors };
|
|
240
371
|
}
|
|
241
372
|
// ─── Generic Post-Restart File Injection ──────────────────────
|
|
242
373
|
/**
|
|
@@ -322,6 +453,70 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
322
453
|
this._currentStepIndex = null;
|
|
323
454
|
this._stepTotal = null;
|
|
324
455
|
}
|
|
456
|
+
/** Delivers an event to {@link PipelineObserver}, swallowing (but logging) any throw. */
|
|
457
|
+
notifyObserver(event) {
|
|
458
|
+
const observer = this.PipelineObserver;
|
|
459
|
+
if (!observer)
|
|
460
|
+
return;
|
|
461
|
+
try {
|
|
462
|
+
observer(event);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
// Progress reporting must never break a schema migration.
|
|
466
|
+
this.rsuLog(`Pipeline observer threw on ${event.Kind} (ignored): ${error instanceof Error ? error.message : String(error)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Records a completed step onto `steps` AND publishes it to the observer, so a step is never
|
|
471
|
+
* visible in the result but absent from the event stream. Every step recording goes through
|
|
472
|
+
* here — including the ones computed inline rather than via {@link runStep}.
|
|
473
|
+
*/
|
|
474
|
+
recordStep(steps, step) {
|
|
475
|
+
steps.push(step);
|
|
476
|
+
this.notifyObserver({
|
|
477
|
+
Kind: 'step.end',
|
|
478
|
+
Name: step.Name,
|
|
479
|
+
Status: step.Status,
|
|
480
|
+
DurationMs: step.DurationMs,
|
|
481
|
+
Message: step.Message,
|
|
482
|
+
StepIndex: step.StepIndex,
|
|
483
|
+
StepTotal: step.StepTotal,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Maps a finished batch onto its terminal `run.end` event.
|
|
488
|
+
*
|
|
489
|
+
* `result` is undefined when a throw escaped the pipeline before a result existed — that case
|
|
490
|
+
* must still produce a FAILED run.end, otherwise an observer's run would hang in-flight forever.
|
|
491
|
+
*
|
|
492
|
+
* Public + static so the mapping is unit-testable without a live pipeline.
|
|
493
|
+
*/
|
|
494
|
+
static BuildRunEndEvent(result, totalCount) {
|
|
495
|
+
if (!result) {
|
|
496
|
+
return {
|
|
497
|
+
Kind: 'run.end',
|
|
498
|
+
Success: false,
|
|
499
|
+
SuccessCount: 0,
|
|
500
|
+
FailureCount: totalCount,
|
|
501
|
+
TotalCount: totalCount,
|
|
502
|
+
ErrorMessage: 'Pipeline threw before producing a result',
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const firstFailure = result.Results.find((r) => !r.Success);
|
|
506
|
+
return {
|
|
507
|
+
Kind: 'run.end',
|
|
508
|
+
Success: result.FailureCount === 0,
|
|
509
|
+
SuccessCount: result.SuccessCount,
|
|
510
|
+
FailureCount: result.FailureCount,
|
|
511
|
+
TotalCount: result.TotalCount,
|
|
512
|
+
ErrorMessage: firstFailure?.ErrorMessage,
|
|
513
|
+
ErrorStep: firstFailure?.ErrorStep,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** Publishes the terminal run boundary. `result` is undefined when a throw escaped the pipeline. */
|
|
517
|
+
notifyRunEnd(result, totalCount) {
|
|
518
|
+
this.notifyObserver(RuntimeSchemaManager.BuildRunEndEvent(result, totalCount));
|
|
519
|
+
}
|
|
325
520
|
// ─── Pipeline ────────────────────────────────────────────────────
|
|
326
521
|
/**
|
|
327
522
|
* Execute the RSU pipeline for a single input. Convenience wrapper
|
|
@@ -360,21 +555,35 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
360
555
|
const sharedSteps = [];
|
|
361
556
|
// U11 — arm the determinate step counter (index of expected total) for this run.
|
|
362
557
|
this.beginStepTracking(inputs.length);
|
|
558
|
+
this.notifyObserver({
|
|
559
|
+
Kind: 'run.start',
|
|
560
|
+
ItemCount: inputs.length,
|
|
561
|
+
Descriptions: inputs.map((i) => i.Description),
|
|
562
|
+
AffectedTables: [...new Set(inputs.flatMap((i) => i.AffectedTables))],
|
|
563
|
+
StepTotal: this._stepTotal ?? 0,
|
|
564
|
+
});
|
|
565
|
+
// Captured so the `finally` can publish the terminal run boundary on EVERY exit path —
|
|
566
|
+
// early validation failure, normal completion, or a throw (which leaves it undefined).
|
|
567
|
+
let batchResult;
|
|
363
568
|
try {
|
|
364
569
|
// Phase 1: Validate
|
|
365
570
|
const validationFailure = await this.validateBatch(inputs, sharedSteps);
|
|
366
|
-
if (validationFailure)
|
|
367
|
-
|
|
571
|
+
if (validationFailure) {
|
|
572
|
+
batchResult = validationFailure;
|
|
573
|
+
return batchResult;
|
|
574
|
+
}
|
|
368
575
|
// Phase 2: Execute migrations under lock
|
|
369
576
|
const itemResults = await this.executeMigrations(inputs, sharedSteps);
|
|
370
577
|
// Phase 3: Post-migration pipeline (CodeGen, compile, restart, git)
|
|
371
578
|
const successfulItems = itemResults.filter((r) => r.Success);
|
|
372
579
|
const postResult = await this.runPostMigrationPipeline(inputs, successfulItems, sharedSteps);
|
|
373
580
|
// Phase 4: Build per-caller results
|
|
374
|
-
|
|
581
|
+
batchResult = this.buildPerCallerResults(itemResults, successfulItems, sharedSteps, postResult);
|
|
582
|
+
return batchResult;
|
|
375
583
|
}
|
|
376
584
|
finally {
|
|
377
585
|
this.endStepTracking();
|
|
586
|
+
this.notifyRunEnd(batchResult, inputs.length);
|
|
378
587
|
}
|
|
379
588
|
}
|
|
380
589
|
/** Phase 1: Validate environment and all migration SQL. Returns a batch failure result if validation fails, null on success. */
|
|
@@ -386,11 +595,11 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
386
595
|
for (const input of inputs) {
|
|
387
596
|
const validation = ValidateMigrationSQL(input.MigrationSQL, this.getProtectedSchemas());
|
|
388
597
|
if (!validation.Valid) {
|
|
389
|
-
|
|
598
|
+
this.recordStep(sharedSteps, { Name: 'ValidateSQL', Status: 'failed', DurationMs: 0, Message: validation.Errors.join('; ') });
|
|
390
599
|
return this.buildBatchResult(inputs.map((i) => this.buildFailedResult(i, 'ValidateSQL', sharedSteps)));
|
|
391
600
|
}
|
|
392
601
|
}
|
|
393
|
-
|
|
602
|
+
this.recordStep(sharedSteps, { Name: 'ValidateSQL', Status: 'success', DurationMs: 0, Message: `Validated ${inputs.length} migration(s)` });
|
|
394
603
|
return null;
|
|
395
604
|
}
|
|
396
605
|
/** Phase 2: Acquire lock, write and execute each migration, release lock. */
|
|
@@ -426,7 +635,7 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
426
635
|
}
|
|
427
636
|
if (itemResults.some((r) => r.Success)) {
|
|
428
637
|
this.MarkOutOfSync();
|
|
429
|
-
|
|
638
|
+
this.recordStep(sharedSteps, { Name: 'MarkOutOfSync', Status: 'success', DurationMs: 0, Message: 'DB changed, API out-of-sync until CodeGen completes' });
|
|
430
639
|
}
|
|
431
640
|
}
|
|
432
641
|
finally {
|
|
@@ -473,6 +682,12 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
473
682
|
// Failed migrations should not trigger post-restart entity maps or syncs.
|
|
474
683
|
const successfulInputs = successfulItems.map(r => r.Input);
|
|
475
684
|
await this.writePostRestartFiles(successfulInputs);
|
|
685
|
+
// Register durable pending work for successful migrations. Rows stay Pending
|
|
686
|
+
// until the post-restart consumer completes them, so a crash between here and
|
|
687
|
+
// consumption leaves the work visible instead of losing it.
|
|
688
|
+
const pendingWork = await this.registerPendingWork(successfulInputs);
|
|
689
|
+
result.PendingWorkIDs = pendingWork.IDs;
|
|
690
|
+
result.PendingWorkErrors = pendingWork.Errors;
|
|
476
691
|
// Restart LAST — PM2 restart kills this process, nothing runs after this
|
|
477
692
|
if (!inputs.every((i) => i.SkipRestart)) {
|
|
478
693
|
const restartOk = await this.runStep('RestartMJAPI', () => this.restartMJAPI(), sharedSteps);
|
|
@@ -493,19 +708,31 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
493
708
|
// A migration that executed but whose run-wide CodeGen failed is NOT a success —
|
|
494
709
|
// the entity may have no spCreate/spUpdate procs and would silently skip on sync.
|
|
495
710
|
const codeGenFailedThisCaller = codeGenFailed && item.Success;
|
|
711
|
+
// Same reasoning as the CodeGen case above: the migration ran, but work the caller
|
|
712
|
+
// asked to happen after the restart was never persisted, so the restart drops it.
|
|
713
|
+
// Reporting success here would tell the caller their sync is coming when it is not.
|
|
714
|
+
const pendingWorkErrors = postResult.PendingWorkErrors?.get(item.Input) ?? [];
|
|
715
|
+
const pendingWorkFailedThisCaller = pendingWorkErrors.length > 0 && item.Success;
|
|
496
716
|
const result = {
|
|
497
|
-
Success: item.Success && successfulItems.length > 0 && !codeGenFailed,
|
|
717
|
+
Success: item.Success && successfulItems.length > 0 && !codeGenFailed && !pendingWorkFailedThisCaller,
|
|
498
718
|
MigrationFilePath: item.FilePath,
|
|
499
719
|
APIRestarted: postResult.ApiRestarted,
|
|
500
720
|
GitCommitSuccess: postResult.GitCommitSuccess,
|
|
501
721
|
BranchName: postResult.BranchName,
|
|
502
722
|
Steps: allSteps,
|
|
503
|
-
ErrorMessage: codeGenFailedThisCaller
|
|
723
|
+
ErrorMessage: codeGenFailedThisCaller
|
|
724
|
+
? postResult.CodeGenError ?? item.Error
|
|
725
|
+
: pendingWorkFailedThisCaller
|
|
726
|
+
? pendingWorkErrors.join('; ')
|
|
727
|
+
: item.Error,
|
|
504
728
|
ErrorStep: codeGenFailedThisCaller
|
|
505
729
|
? 'RunCodeGen'
|
|
506
|
-
:
|
|
507
|
-
?
|
|
508
|
-
:
|
|
730
|
+
: pendingWorkFailedThisCaller
|
|
731
|
+
? 'RegisterPendingWork'
|
|
732
|
+
: item.Error
|
|
733
|
+
? item.Steps.find((s) => s.Status === 'failed')?.Name
|
|
734
|
+
: undefined,
|
|
735
|
+
PendingWorkIDs: postResult.PendingWorkIDs?.get(item.Input),
|
|
509
736
|
};
|
|
510
737
|
this.writeAuditLog(item.Input, result).catch((err) => LogError(`[RSU] Audit log failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
511
738
|
this.recordMetrics(item, allSteps, result);
|
|
@@ -665,7 +892,6 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
665
892
|
* Verify ALL filesystem paths RSU may write to are actually writable.
|
|
666
893
|
* Paths checked:
|
|
667
894
|
* - MigrationsPath (migration SQL files)
|
|
668
|
-
* - PendingWorkPath (post-restart task JSONs)
|
|
669
895
|
* - AdditionalSchemaInfoPath parent dir (soft FK / soft PK config)
|
|
670
896
|
* - CodeGenDir (temp codegen script)
|
|
671
897
|
* - WorkDir (pipeline log file)
|
|
@@ -676,7 +902,6 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
676
902
|
const workDir = rsuConfig.WorkDir;
|
|
677
903
|
const directoriesToCheck = [
|
|
678
904
|
nodePath.join(workDir, rsuConfig.MigrationsPath),
|
|
679
|
-
nodePath.join(workDir, rsuConfig.PendingWorkPath),
|
|
680
905
|
dirname(nodePath.join(workDir, this.additionalSchemaInfoPath)),
|
|
681
906
|
rsuConfig.CodeGenDir,
|
|
682
907
|
workDir, // pipeline log
|
|
@@ -1022,8 +1247,9 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
1022
1247
|
const processName = rsuConfig.PM2ProcessName;
|
|
1023
1248
|
// When running inside the MJAPI process that PM2 manages, `pm2 restart`
|
|
1024
1249
|
// sends SIGINT to THIS process. We will die before execAsync resolves.
|
|
1025
|
-
// That's fine — the pending-work
|
|
1026
|
-
// before this step, and the newly restarted process
|
|
1250
|
+
// That's fine — the pending-work rows were already committed to
|
|
1251
|
+
// `MJ: RSU Pending Works` before this step, and the newly restarted process
|
|
1252
|
+
// picks them up on boot.
|
|
1027
1253
|
//
|
|
1028
1254
|
// Strategy: fire the restart command, catch the inevitable rejection
|
|
1029
1255
|
// (our process gets killed mid-flight), and return true. If the process
|
|
@@ -1579,18 +1805,19 @@ export class RuntimeSchemaManager extends BaseSingleton {
|
|
|
1579
1805
|
const stepIndex = this._currentStepIndex ?? undefined;
|
|
1580
1806
|
const stepTotal = this._stepTotal ?? undefined;
|
|
1581
1807
|
this.rsuLog(`▶ Starting step${stepIndex && stepTotal ? ` ${stepIndex}/${stepTotal}` : ''}: ${name}`);
|
|
1808
|
+
this.notifyObserver({ Kind: 'step.start', Name: name, StepIndex: stepIndex, StepTotal: stepTotal });
|
|
1582
1809
|
try {
|
|
1583
1810
|
const result = await fn();
|
|
1584
1811
|
const durationMs = Date.now() - start;
|
|
1585
1812
|
const msg = `${name} completed successfully`;
|
|
1586
|
-
|
|
1813
|
+
this.recordStep(steps, { Name: name, Status: 'success', DurationMs: durationMs, Message: msg, StepIndex: stepIndex, StepTotal: stepTotal });
|
|
1587
1814
|
this.rsuLog(`✓ ${name} — ${durationMs}ms`);
|
|
1588
1815
|
return result;
|
|
1589
1816
|
}
|
|
1590
1817
|
catch (error) {
|
|
1591
1818
|
const durationMs = Date.now() - start;
|
|
1592
1819
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1593
|
-
|
|
1820
|
+
this.recordStep(steps, { Name: name, Status: 'failed', DurationMs: durationMs, Message: msg, StepIndex: stepIndex, StepTotal: stepTotal });
|
|
1594
1821
|
this.rsuLog(`✗ ${name} — FAILED after ${durationMs}ms: ${msg}`);
|
|
1595
1822
|
return undefined;
|
|
1596
1823
|
}
|