@memberjunction/open-app-engine 5.45.1 → 5.47.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.
@@ -1,6 +1,6 @@
1
1
  import { tmpdir } from 'node:os';
2
2
  import { join } from 'node:path';
3
- import { mkdirSync, readFileSync } from 'node:fs';
3
+ import { mkdirSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { ParseAndValidateManifest } from '../manifest/manifest-loader.js';
@@ -9,13 +9,40 @@ import { ResolveDependencyGraph } from '../dependency/dependency-graph-builder.j
9
9
  import { FetchManifestFromGitHub, DownloadMigrations, GetLatestVersion, ListGitHubReleases, ListGitHubTags, ValidateGitHubTag, ParseGitHubUrl } from '../github/github-client.js';
10
10
  import semver from 'semver';
11
11
  import { CreateAppSchema, DropAppSchema, SchemaExists, EscapeSqlString } from './schema-manager.js';
12
+ import { RunFkGraphTeardown, buildRootDoomedPredicate } from './entity-teardown.js';
13
+ import { extractApplicationIds } from './migration-application-ids.js';
12
14
  import { RunAppMigrations } from './migration-runner.js';
13
15
  import { AddAppPackages, RemoveAppPackages, RunPackageInstall, BumpPrefixedDependencies } from './package-manager.js';
14
16
  import { AddServerDynamicPackages, AddClientDynamicPackages, RemoveServerDynamicPackages, ToggleServerDynamicPackages, AddEntityPackageMapping, RemoveEntityPackageMapping } from './config-manager.js';
15
17
  import { AngularConfigManager } from './angular-config-manager.js';
16
18
  import { Metadata, RunView } from '@memberjunction/core';
17
19
  import { NormalizeUUID } from '@memberjunction/global';
18
- import { RecordAppInstallation, RecordInstallHistoryEntry, RecordAppDependencies, ReplaceAppDependenciesAtomically, SetAppStatus, FindInstalledApp, CheckSchemaSharedByOtherApps, FindDependentApps, ListInstalledApps, UpdateAppRecord, } from './history-recorder.js';
20
+ import { RecordAppInstallation, RecordInstallHistoryEntry, RecordAppDependencies, DeleteAppDependencies, ReplaceAppDependenciesAtomically, SetAppStatus, FindInstalledApp, CheckSchemaSharedByOtherApps, FindDependentApps, ListInstalledApps, UpdateAppRecord, SetAppStep, } from './history-recorder.js';
21
+ const INSTALL_STEP_ORDER = ['RecordCreated', 'PackagesInstalled', 'ConfigUpdated', 'AngularExcludesUpdated', 'Finalized', 'HooksRun'];
22
+ const UPGRADE_STEP_ORDER = ['MigrationsApplied', 'PackagesInstalled', 'ConfigUpdated', 'AngularExcludesUpdated', 'RecordUpdated', 'HooksRun', 'DependenciesReplaced'];
23
+ const REMOVE_STEP_ORDER = ['DbCleanupDone', 'FilesRemoved'];
24
+ function IsStepDone(order, checkpoint, step) {
25
+ if (!checkpoint) {
26
+ return false;
27
+ }
28
+ const checkpointIndex = order.indexOf(checkpoint);
29
+ const stepIndex = order.indexOf(step);
30
+ return checkpointIndex >= 0 && checkpointIndex >= stepIndex;
31
+ }
32
+ function IsOwnCheckpoint(order, checkpoint) {
33
+ return !!checkpoint && order.includes(checkpoint);
34
+ }
35
+ const SETTLED_STATUSES = new Set(['Active', 'Disabled']);
36
+ function BuildMutexConflictResult(action, appName, version, startTime, currentStatus, displayVerb) {
37
+ const verb = displayVerb ?? action.toLowerCase();
38
+ const guidance = {
39
+ Installing: `finish it with 'mj app install <source>' (it will resume automatically)`,
40
+ Upgrading: `finish it with 'mj app upgrade ${appName}' (it will resume automatically)`,
41
+ Removing: `finish it with 'mj app remove ${appName}' (it will resume automatically)`,
42
+ Error: `resolve it — 'mj app install <source>' to retry a failed install, 'mj app upgrade ${appName}' to retry a failed upgrade, or 'mj app remove ${appName}' to remove it`,
43
+ };
44
+ return BuildFailureResult(action, appName, version, 'Schema', startTime, `Cannot ${verb} '${appName}' while it is '${currentStatus}' — another operation is still in progress or failed partway. ${guidance[currentStatus] ?? 'Resolve the in-progress operation first.'}`);
45
+ }
19
46
  class OpenAppPhaseError extends Error {
20
47
  constructor(Phase, message) {
21
48
  super(message);
@@ -73,83 +100,120 @@ export async function InstallApp(options, context) {
73
100
  }
74
101
  const existingApp = await FindInstalledApp(context.ContextUser, manifest.name);
75
102
  const isReinstall = existingApp != null && (existingApp.Status === 'Removed' || existingApp.Status === 'Error');
76
- if (existingApp && !isReinstall) {
77
- return BuildFailureResult('Install', manifest.name, manifest.version, 'Schema', startTime, `App '${manifest.name}' is already installed with status '${existingApp.Status}'. Use 'mj app upgrade' to update it.`);
103
+ const isResume = existingApp != null && existingApp.Status === 'Installing' && existingApp.Version === manifest.version;
104
+ if (existingApp && !isReinstall && !isResume) {
105
+ const guidance = {
106
+ Active: `Use 'mj app upgrade ${manifest.name}' to update it.`,
107
+ Disabled: `Use 'mj app upgrade ${manifest.name}' to update it.`,
108
+ Upgrading: `An upgrade is already in progress — finish it with 'mj app upgrade ${manifest.name}' (it will resume automatically), then re-run this install if still needed.`,
109
+ Removing: `A removal is already in progress — finish it with 'mj app remove ${manifest.name}' (it will resume automatically), then re-run this install once it's fully removed.`,
110
+ };
111
+ return BuildFailureResult('Install', manifest.name, manifest.version, 'Schema', startTime, `App '${manifest.name}' is already installed with status '${existingApp.Status}'. ${guidance[existingApp.Status] ?? `Resolve the '${existingApp.Status}' state first.`}`);
112
+ }
113
+ const resumeCheckpoint = isResume ? (existingApp.LastCompletedStep ?? null) : null;
114
+ const phase1Complete = isResume && resumeCheckpoint != null;
115
+ if (isResume) {
116
+ Callbacks?.OnProgress?.('Resume', `Resuming install of ${manifest.name} v${manifest.version}${resumeCheckpoint ? ` from step '${resumeCheckpoint}'` : ''}...`);
78
117
  }
79
- Callbacks?.OnProgress?.('Install', `Installing ${manifest.name} v${manifest.version}...`);
80
118
  let schemaCreated = false;
81
- if (manifest.schema) {
82
- const schemaResult = await HandleSchemaCreation(manifest, context, isReinstall, options.AllowDoubleUnderscoreSchema === true);
83
- if (!schemaResult.Success) {
84
- return BuildFailureResult('Install', manifest.name, manifest.version, 'Schema', startTime, schemaResult.ErrorMessage ?? 'Schema creation failed');
85
- }
86
- schemaCreated = schemaResult.Created === true;
87
- await PersistCanonicalSchemaName(manifest, context);
119
+ if (phase1Complete) {
120
+ createdAppId = existingApp.ID;
88
121
  }
89
- if (manifest.migrations && manifest.schema) {
90
- const migrationResult = await HandleMigrations(manifest, context, subpath);
91
- if (!migrationResult.Success) {
122
+ else {
123
+ Callbacks?.OnProgress?.('Install', `Installing ${manifest.name} v${manifest.version}...`);
124
+ if (existingApp?.LastCompletedStep) {
125
+ await SetAppStep(context.ContextUser, existingApp.ID, null);
126
+ }
127
+ if (manifest.schema) {
128
+ const schemaResult = await HandleSchemaCreation(manifest, context, isReinstall || isResume, options.AllowDoubleUnderscoreSchema === true);
129
+ if (!schemaResult.Success) {
130
+ return BuildFailureResult('Install', manifest.name, manifest.version, 'Schema', startTime, schemaResult.ErrorMessage ?? 'Schema creation failed');
131
+ }
132
+ schemaCreated = schemaResult.Created === true;
133
+ await PersistCanonicalSchemaName(manifest, context);
134
+ }
135
+ if (manifest.migrations && manifest.schema) {
136
+ const migrationResult = await HandleMigrations(manifest, context, subpath);
137
+ if (!migrationResult.Success) {
138
+ await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
139
+ return BuildFailureResult('Install', manifest.name, manifest.version, 'Migration', startTime, migrationResult.ErrorMessage ?? 'Migration failed');
140
+ }
141
+ }
142
+ Callbacks?.OnProgress?.('Record', 'Recording app installation...');
143
+ const recordResult = await RecordInstallationAtomically(context.ContextUser, manifest, Callbacks, undefined, subpath);
144
+ if (!recordResult.Success) {
92
145
  await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
93
- return BuildFailureResult('Install', manifest.name, manifest.version, 'Migration', startTime, migrationResult.ErrorMessage ?? 'Migration failed');
146
+ return BuildFailureResult('Install', manifest.name, manifest.version, 'Record', startTime, recordResult.ErrorMessage ?? 'Failed to record installation');
94
147
  }
148
+ createdAppId = recordResult.AppId;
149
+ await SetAppStep(context.ContextUser, createdAppId, 'RecordCreated');
95
150
  }
96
- Callbacks?.OnProgress?.('Record', 'Recording app installation...');
97
- const recordResult = await RecordInstallationAtomically(context.ContextUser, manifest, Callbacks, undefined, subpath);
98
- if (!recordResult.Success) {
99
- await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
100
- return BuildFailureResult('Install', manifest.name, manifest.version, 'Record', startTime, recordResult.ErrorMessage ?? 'Failed to record installation');
101
- }
102
- createdAppId = recordResult.AppId;
103
151
  Callbacks?.OnProgress?.('Config', 'Updating configuration files...');
104
- const pkgResult = await HandlePackageInstallation(manifest, context, effectivePackageVersion, effectiveVersionStrategy, options.Verbose);
105
152
  let npmInstallWarning;
106
- if (!pkgResult.Success) {
107
- if (pkgResult.PackageJsonUpdated) {
108
- npmInstallWarning = pkgResult.ErrorMessage;
109
- Callbacks?.OnWarn?.('Packages', `npm install failed — package.json entries were added but dependencies were not resolved. Run 'npm install' manually after fixing npm auth.\n Detail: ${pkgResult.ErrorMessage}`);
153
+ if (!IsStepDone(INSTALL_STEP_ORDER, resumeCheckpoint, 'PackagesInstalled')) {
154
+ const pkgResult = await HandlePackageInstallation(manifest, context, effectivePackageVersion, effectiveVersionStrategy, options.Verbose);
155
+ if (!pkgResult.Success) {
156
+ if (pkgResult.PackageJsonUpdated) {
157
+ npmInstallWarning = pkgResult.ErrorMessage;
158
+ Callbacks?.OnWarn?.('Packages', `npm install failed — package.json entries were added but dependencies were not resolved. Run 'npm install' manually after fixing npm auth.\n Detail: ${pkgResult.ErrorMessage}`);
159
+ }
160
+ else {
161
+ await SetAppStatus(context.ContextUser, createdAppId, 'Error');
162
+ await RecordFailureHistory(context.ContextUser, createdAppId, 'Install', manifest, 'Packages', pkgResult.ErrorMessage ?? 'Package installation failed', startTime);
163
+ return BuildFailureResult('Install', manifest.name, manifest.version, 'Packages', startTime, pkgResult.ErrorMessage ?? 'Package installation failed');
164
+ }
110
165
  }
111
166
  else {
167
+ await SetAppStep(context.ContextUser, createdAppId, 'PackagesInstalled');
168
+ }
169
+ }
170
+ if (!IsStepDone(INSTALL_STEP_ORDER, resumeCheckpoint, 'ConfigUpdated')) {
171
+ const configResult = HandleServerConfig(manifest, context);
172
+ if (!configResult.Success) {
112
173
  await SetAppStatus(context.ContextUser, createdAppId, 'Error');
113
- await RecordFailureHistory(context.ContextUser, createdAppId, 'Install', manifest, 'Packages', pkgResult.ErrorMessage ?? 'Package installation failed', startTime);
114
- return BuildFailureResult('Install', manifest.name, manifest.version, 'Packages', startTime, pkgResult.ErrorMessage ?? 'Package installation failed');
174
+ await RecordFailureHistory(context.ContextUser, createdAppId, 'Install', manifest, 'Config', configResult.ErrorMessage ?? 'Config update failed', startTime);
175
+ return BuildFailureResult('Install', manifest.name, manifest.version, 'Config', startTime, configResult.ErrorMessage ?? 'Config update failed');
115
176
  }
177
+ await SetAppStep(context.ContextUser, createdAppId, 'ConfigUpdated');
116
178
  }
117
- const configResult = HandleServerConfig(manifest, context);
118
- if (!configResult.Success) {
119
- await SetAppStatus(context.ContextUser, createdAppId, 'Error');
120
- await RecordFailureHistory(context.ContextUser, createdAppId, 'Install', manifest, 'Config', configResult.ErrorMessage ?? 'Config update failed', startTime);
121
- return BuildFailureResult('Install', manifest.name, manifest.version, 'Config', startTime, configResult.ErrorMessage ?? 'Config update failed');
179
+ if (!IsStepDone(INSTALL_STEP_ORDER, resumeCheckpoint, 'AngularExcludesUpdated')) {
180
+ HandleAngularPrebundleExcludes(manifest, context);
181
+ await SetAppStep(context.ContextUser, createdAppId, 'AngularExcludesUpdated');
122
182
  }
123
- HandleAngularPrebundleExcludes(manifest, context);
124
183
  Callbacks?.OnProgress?.('Record', 'Finalizing installation...');
125
184
  const finalStatus = npmInstallWarning ? 'Disabled' : 'Active';
126
185
  await SetAppStatus(context.ContextUser, createdAppId, finalStatus);
127
186
  if (finalStatus !== 'Active') {
128
187
  ToggleServerDynamicPackages(context.RepoRoot, manifest.name, false, context.ServerPackagePath);
129
188
  }
130
- if (manifest.hooks?.postInstall) {
131
- Callbacks?.OnProgress?.('Hooks', 'Running postInstall hook...');
132
- await ExecuteHook(manifest.hooks.postInstall, context.RepoRoot);
133
- }
134
- if (manifest.hooks?.postInstallModule) {
135
- Callbacks?.OnProgress?.('Hooks', `Running postInstall module '${manifest.hooks.postInstallModule}'...`);
136
- const appInfo = await FindInstalledApp(context.ContextUser, manifest.name);
137
- await ExecuteHookModule(manifest.hooks.postInstallModule, {
138
- App: appInfo,
139
- RepoRoot: context.RepoRoot,
140
- Provider: context.DatabaseProvider,
141
- ContextUser: context.ContextUser,
142
- Callbacks: context.Callbacks,
143
- Manifest: manifest,
144
- }, context.RepoRoot);
189
+ await SetAppStep(context.ContextUser, createdAppId, 'Finalized');
190
+ if (!IsStepDone(INSTALL_STEP_ORDER, resumeCheckpoint, 'HooksRun')) {
191
+ if (manifest.hooks?.postInstall) {
192
+ Callbacks?.OnProgress?.('Hooks', 'Running postInstall hook...');
193
+ await ExecuteHook(manifest.hooks.postInstall, context.RepoRoot);
194
+ }
195
+ if (manifest.hooks?.postInstallModule) {
196
+ Callbacks?.OnProgress?.('Hooks', `Running postInstall module '${manifest.hooks.postInstallModule}'...`);
197
+ const appInfo = await FindInstalledApp(context.ContextUser, manifest.name);
198
+ await ExecuteHookModule(manifest.hooks.postInstallModule, {
199
+ App: appInfo,
200
+ RepoRoot: context.RepoRoot,
201
+ Provider: context.DatabaseProvider,
202
+ ContextUser: context.ContextUser,
203
+ Callbacks: context.Callbacks,
204
+ Manifest: manifest,
205
+ }, context.RepoRoot);
206
+ }
207
+ await SetAppStep(context.ContextUser, createdAppId, 'HooksRun');
145
208
  }
209
+ await SetAppStep(context.ContextUser, createdAppId, null);
146
210
  try {
147
211
  await RecordInstallHistoryEntry(context.ContextUser, createdAppId, 'Install', manifest, {
148
212
  Success: true,
149
213
  DurationSeconds: GetDurationSeconds(startTime),
150
214
  StartedAt: new Date(startTime),
151
215
  EndedAt: new Date(),
152
- Summary: 'Initial installation',
216
+ Summary: isResume ? 'Initial installation (resumed after a prior interrupted attempt)' : 'Initial installation',
153
217
  });
154
218
  }
155
219
  catch (histErr) {
@@ -196,6 +260,7 @@ async function RecordInstallationAtomically(contextUser, manifest, callbacks, pr
196
260
  try {
197
261
  const appId = await RecordAppInstallation(contextUser, manifest, callbacks, tg, 'Installing', provider, subpath);
198
262
  if (manifest.dependencies) {
263
+ await DeleteAppDependencies(contextUser, appId, tg);
199
264
  await RecordAppDependencies(contextUser, appId, manifest.dependencies, tg);
200
265
  }
201
266
  callbacks?.OnProgress?.('Record', 'Committing installation records...');
@@ -236,6 +301,9 @@ export async function UpgradeApp(options, context) {
236
301
  return BuildFailureResult('Upgrade', options.AppName, '', 'Schema', startTime, `App '${options.AppName}' is not installed`);
237
302
  }
238
303
  upgradeAppId = existingApp.ID;
304
+ if (existingApp.Status === 'Installing' || existingApp.Status === 'Removing') {
305
+ return BuildMutexConflictResult('Upgrade', options.AppName, existingApp.Version, startTime, existingApp.Status);
306
+ }
239
307
  previousVersion = existingApp.Version;
240
308
  const explicitUpgradeVersion = options.Version;
241
309
  const targetVersion = explicitUpgradeVersion ?? (await GetLatestVersion(existingApp.RepositoryURL, context.GitHubOptions, existingApp.Subpath ?? undefined));
@@ -292,8 +360,19 @@ export async function UpgradeApp(options, context) {
292
360
  }
293
361
  }
294
362
  }
363
+ const checkpointTargetsThisVersion = existingApp.LastCompletedStepTargetVersion === manifest.version;
364
+ const resumeCheckpoint = (existingApp.Status === 'Upgrading' || existingApp.Status === 'Error') && checkpointTargetsThisVersion ? (existingApp.LastCompletedStep ?? null) : null;
365
+ if (resumeCheckpoint) {
366
+ Callbacks?.OnProgress?.('Resume', `Resuming upgrade of ${options.AppName} to v${manifest.version} from step '${resumeCheckpoint}'...`);
367
+ }
368
+ else if ((existingApp.Status === 'Upgrading' || existingApp.Status === 'Error') && existingApp.LastCompletedStep && !checkpointTargetsThisVersion) {
369
+ Callbacks?.OnProgress?.('Resume', `A previous upgrade attempt targeting a different version (${existingApp.LastCompletedStepTargetVersion ?? 'unknown'}) was left in progress — starting the upgrade to v${manifest.version} from the top instead of resuming it.`);
370
+ }
371
+ if (!resumeCheckpoint && existingApp.LastCompletedStep) {
372
+ await SetAppStep(context.ContextUser, existingApp.ID, null);
373
+ }
295
374
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Upgrading');
296
- if (manifest.migrations && manifest.schema) {
375
+ if (!IsStepDone(UPGRADE_STEP_ORDER, resumeCheckpoint, 'MigrationsApplied') && manifest.migrations && manifest.schema) {
297
376
  const migrationResult = await HandleMigrations(manifest, context, subpath);
298
377
  if (!migrationResult.Success) {
299
378
  const detail = migrationResult.ErrorMessage ?? 'Migration failed';
@@ -305,55 +384,75 @@ export async function UpgradeApp(options, context) {
305
384
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
306
385
  return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Migration', startTime, recoverable);
307
386
  }
387
+ await SetAppStep(context.ContextUser, existingApp.ID, 'MigrationsApplied', undefined, manifest.version);
308
388
  }
309
- const effectiveUpgradeVersion = explicitUpgradeVersion ? targetVersion.replace(/^v/, '') : manifest.version;
310
- const effectiveUpgradeStrategy = explicitUpgradeVersion ? 'exact' : context.VersionStrategy;
311
- const pkgResult = await HandlePackageInstallation(manifest, context, effectiveUpgradeVersion, effectiveUpgradeStrategy, options.Verbose);
312
389
  let npmInstallWarning;
313
- if (!pkgResult.Success) {
314
- if (pkgResult.PackageJsonUpdated) {
315
- npmInstallWarning = pkgResult.ErrorMessage;
316
- Callbacks?.OnWarn?.('Packages', `npm install failed — package.json entries were updated but dependencies were not resolved. Run 'npm install' manually after fixing npm auth.\n Detail: ${pkgResult.ErrorMessage}`);
390
+ if (!IsStepDone(UPGRADE_STEP_ORDER, resumeCheckpoint, 'PackagesInstalled')) {
391
+ const effectiveUpgradeVersion = explicitUpgradeVersion ? targetVersion.replace(/^v/, '') : manifest.version;
392
+ const effectiveUpgradeStrategy = explicitUpgradeVersion ? 'exact' : context.VersionStrategy;
393
+ const pkgResult = await HandlePackageInstallation(manifest, context, effectiveUpgradeVersion, effectiveUpgradeStrategy, options.Verbose);
394
+ if (!pkgResult.Success) {
395
+ if (pkgResult.PackageJsonUpdated) {
396
+ npmInstallWarning = pkgResult.ErrorMessage;
397
+ Callbacks?.OnWarn?.('Packages', `npm install failed — package.json entries were updated but dependencies were not resolved. Run 'npm install' manually after fixing npm auth.\n Detail: ${pkgResult.ErrorMessage}`);
398
+ }
399
+ else {
400
+ await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Packages', pkgResult.ErrorMessage ?? 'Package update failed', startTime, previousVersion);
401
+ await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
402
+ return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Packages', startTime, pkgResult.ErrorMessage ?? 'Package update failed');
403
+ }
317
404
  }
318
405
  else {
319
- await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Packages', pkgResult.ErrorMessage ?? 'Package update failed', startTime, previousVersion);
406
+ await SetAppStep(context.ContextUser, existingApp.ID, 'PackagesInstalled', undefined, manifest.version);
407
+ }
408
+ }
409
+ if (!IsStepDone(UPGRADE_STEP_ORDER, resumeCheckpoint, 'ConfigUpdated')) {
410
+ const configResult = HandleServerConfig(manifest, context);
411
+ if (!configResult.Success) {
412
+ await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Config', configResult.ErrorMessage ?? 'Config update failed', startTime, previousVersion);
320
413
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
321
- return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Packages', startTime, pkgResult.ErrorMessage ?? 'Package update failed');
414
+ return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Config', startTime, configResult.ErrorMessage ?? 'Config update failed');
322
415
  }
416
+ await SetAppStep(context.ContextUser, existingApp.ID, 'ConfigUpdated', undefined, manifest.version);
323
417
  }
324
- const configResult = HandleServerConfig(manifest, context);
325
- if (!configResult.Success) {
326
- await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Config', configResult.ErrorMessage ?? 'Config update failed', startTime, previousVersion);
327
- await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
328
- return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Config', startTime, configResult.ErrorMessage ?? 'Config update failed');
418
+ if (!IsStepDone(UPGRADE_STEP_ORDER, resumeCheckpoint, 'AngularExcludesUpdated')) {
419
+ HandleAngularPrebundleExcludes(manifest, context);
420
+ await SetAppStep(context.ContextUser, existingApp.ID, 'AngularExcludesUpdated', undefined, manifest.version);
329
421
  }
330
- HandleAngularPrebundleExcludes(manifest, context);
331
422
  await UpdateAppRecord(context.ContextUser, existingApp.ID, {
332
423
  Version: manifest.version,
333
424
  ManifestJSON: JSON.stringify(manifest),
334
425
  Status: 'Active',
335
426
  });
336
- if (manifest.hooks?.postUpgrade) {
337
- Callbacks?.OnProgress?.('Hooks', 'Running postUpgrade hook...');
338
- await ExecuteHook(manifest.hooks.postUpgrade, context.RepoRoot);
339
- }
340
- if (manifest.hooks?.postUpgradeModule) {
341
- Callbacks?.OnProgress?.('Hooks', `Running postUpgrade module '${manifest.hooks.postUpgradeModule}'...`);
342
- await ExecuteHookModule(manifest.hooks.postUpgradeModule, {
343
- App: existingApp,
344
- RepoRoot: context.RepoRoot,
345
- Provider: context.DatabaseProvider,
346
- ContextUser: context.ContextUser,
347
- Callbacks: context.Callbacks,
348
- Manifest: manifest,
349
- }, context.RepoRoot);
427
+ await SetAppStep(context.ContextUser, existingApp.ID, 'RecordUpdated', undefined, manifest.version);
428
+ if (!IsStepDone(UPGRADE_STEP_ORDER, resumeCheckpoint, 'HooksRun')) {
429
+ if (manifest.hooks?.postUpgrade) {
430
+ Callbacks?.OnProgress?.('Hooks', 'Running postUpgrade hook...');
431
+ await ExecuteHook(manifest.hooks.postUpgrade, context.RepoRoot);
432
+ }
433
+ if (manifest.hooks?.postUpgradeModule) {
434
+ Callbacks?.OnProgress?.('Hooks', `Running postUpgrade module '${manifest.hooks.postUpgradeModule}'...`);
435
+ await ExecuteHookModule(manifest.hooks.postUpgradeModule, {
436
+ App: existingApp,
437
+ RepoRoot: context.RepoRoot,
438
+ Provider: context.DatabaseProvider,
439
+ ContextUser: context.ContextUser,
440
+ Callbacks: context.Callbacks,
441
+ Manifest: manifest,
442
+ }, context.RepoRoot);
443
+ }
444
+ await SetAppStep(context.ContextUser, existingApp.ID, 'HooksRun', undefined, manifest.version);
350
445
  }
351
- if (manifest.dependencies) {
446
+ if (!IsStepDone(UPGRADE_STEP_ORDER, resumeCheckpoint, 'DependenciesReplaced') && manifest.dependencies) {
352
447
  const depsReplaced = await ReplaceAppDependenciesAtomically(context.ContextUser, existingApp.ID, manifest.dependencies);
353
448
  if (!depsReplaced) {
354
449
  Callbacks?.OnWarn?.('Record', 'App upgraded, but its dependency records could not be updated atomically — re-run the upgrade to refresh them.');
355
450
  }
451
+ else {
452
+ await SetAppStep(context.ContextUser, existingApp.ID, 'DependenciesReplaced', undefined, manifest.version);
453
+ }
356
454
  }
455
+ await SetAppStep(context.ContextUser, existingApp.ID, null, undefined, manifest.version);
357
456
  try {
358
457
  await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Upgrade', manifest, {
359
458
  PreviousVersion: previousVersion,
@@ -409,12 +508,23 @@ export async function RemoveApp(options, context) {
409
508
  return BuildFailureResult('Remove', options.AppName, '', 'Schema', startTime, `App '${options.AppName}' is not installed`);
410
509
  }
411
510
  removeAppId = existingApp.ID;
511
+ if (existingApp.Status === 'Installing' || existingApp.Status === 'Upgrading') {
512
+ return BuildMutexConflictResult('Remove', options.AppName, existingApp.Version, startTime, existingApp.Status);
513
+ }
412
514
  if (!options.Force) {
413
515
  const dependents = await FindDependentApps(context.ContextUser, options.AppName);
414
516
  if (dependents.length > 0) {
415
517
  return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, `Cannot remove: the following apps depend on ${options.AppName}: ${dependents.join(', ')}. Use --force to override.`);
416
518
  }
417
519
  }
520
+ const rawCheckpoint = existingApp.Status === 'Removing' || existingApp.Status === 'Error' ? (existingApp.LastCompletedStep ?? null) : null;
521
+ const resumeCheckpoint = IsOwnCheckpoint(REMOVE_STEP_ORDER, rawCheckpoint) ? rawCheckpoint : null;
522
+ if (existingApp.LastCompletedStep && !resumeCheckpoint) {
523
+ await SetAppStep(context.ContextUser, existingApp.ID, null);
524
+ }
525
+ if (resumeCheckpoint) {
526
+ Callbacks?.OnProgress?.('Resume', `Resuming removal of ${options.AppName} from step '${resumeCheckpoint}'...`);
527
+ }
418
528
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Removing');
419
529
  const manifest = JSON.parse(existingApp.ManifestJSON);
420
530
  removeManifest = manifest;
@@ -433,90 +543,106 @@ export async function RemoveApp(options, context) {
433
543
  Manifest: manifest,
434
544
  }, context.RepoRoot);
435
545
  }
436
- const shareCheck = existingApp.SchemaName
437
- ? await CheckSchemaSharedByOtherApps(context.ContextUser, existingApp.SchemaName, existingApp.ID)
438
- : { Shared: false, CheckFailed: false };
439
- const schemaShared = shareCheck.Shared;
440
- const shareCheckError = shareCheck.CheckFailed
441
- ? `Could not determine whether schema '${existingApp.SchemaName}' is shared by other Open Apps (share-check query failed): ${shareCheck.ErrorMessage ?? 'unknown error'}`
442
- : undefined;
443
- if (schemaShared && !shareCheck.CheckFailed) {
444
- Callbacks?.OnWarn?.('Schema', `Schema '${existingApp.SchemaName}' is still used by another installed Open App — skipping metadata + schema removal to protect co-tenant data.`);
445
- }
446
- let metadataResult = { Success: true };
447
- if (existingApp.SchemaName && !schemaShared) {
448
- Callbacks?.OnProgress?.('Metadata', `Removing entity metadata for schema '${existingApp.SchemaName}'...`);
449
- metadataResult = await RemoveAppEntityMetadata(existingApp.SchemaName, context.ContextUser, Callbacks);
450
- }
451
- let teardownResult = { Success: true };
452
- if (!options.KeepData) {
453
- teardownResult = await HandleTeardown(manifest, context, existingApp.Subpath ?? undefined);
454
- if (!teardownResult.Success) {
455
- Callbacks?.OnError?.('Metadata', `Teardown failed: ${teardownResult.ErrorMessage}`);
546
+ if (!IsStepDone(REMOVE_STEP_ORDER, resumeCheckpoint, 'DbCleanupDone')) {
547
+ const shareCheck = existingApp.SchemaName
548
+ ? await CheckSchemaSharedByOtherApps(context.ContextUser, existingApp.SchemaName, existingApp.ID)
549
+ : { Shared: false, CheckFailed: false };
550
+ const schemaShared = shareCheck.Shared;
551
+ const shareCheckError = shareCheck.CheckFailed
552
+ ? `Could not determine whether schema '${existingApp.SchemaName}' is shared by other Open Apps (share-check query failed): ${shareCheck.ErrorMessage ?? 'unknown error'}`
553
+ : undefined;
554
+ if (schemaShared && !shareCheck.CheckFailed) {
555
+ Callbacks?.OnWarn?.('Schema', `Schema '${existingApp.SchemaName}' is still used by another installed Open App — skipping metadata + schema removal to protect co-tenant data.`);
456
556
  }
457
- }
458
- let schemaDropError;
459
- if (!options.KeepData && existingApp.SchemaName && !schemaShared) {
460
- Callbacks?.OnProgress?.('Schema', `Dropping schema '${existingApp.SchemaName}'...`);
461
- const dropResult = await DropAppSchema(existingApp.SchemaName, context.DatabaseProvider, {
462
- allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema === true,
463
- });
464
- if (!dropResult.Success) {
465
- schemaDropError = dropResult.ErrorMessage;
466
- Callbacks?.OnError?.('Schema', `Failed to drop schema: ${dropResult.ErrorMessage}`);
557
+ let metadataResult = { Success: true };
558
+ if (existingApp.SchemaName && !schemaShared) {
559
+ Callbacks?.OnProgress?.('Metadata', `Removing entity metadata for schema '${existingApp.SchemaName}'...`);
560
+ const declaredApplicationIds = await ExtractDeclaredApplicationIds(manifest, context, existingApp.Subpath ?? undefined);
561
+ metadataResult = await RemoveAppEntityMetadata(existingApp.SchemaName, context.ContextUser, Callbacks, context.DatabaseProvider, {
562
+ DatabaseProvider: context.DatabaseProvider,
563
+ MJCoreSchema: context.MJCoreSchema,
564
+ DeclaredApplicationIds: declaredApplicationIds,
565
+ });
467
566
  }
468
- }
469
- const removalErrors = [
470
- shareCheckError,
471
- teardownResult.Success ? undefined : teardownResult.ErrorMessage,
472
- metadataResult.Success ? undefined : metadataResult.ErrorMessage,
473
- schemaDropError,
474
- ].filter((e) => !!e);
475
- if (removalErrors.length > 0) {
476
- const combined = removalErrors.join('; ');
477
- await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
478
- Success: false,
479
- DurationSeconds: GetDurationSeconds(startTime),
480
- StartedAt: new Date(startTime),
481
- EndedAt: new Date(),
482
- Summary: `Remove failed: ${combined}`,
483
- });
484
- await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
485
- return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, combined);
486
- }
487
- Callbacks?.OnProgress?.('Config', 'Removing config, client bootstrap, and package references...');
488
- const otherApps = (await ListInstalledApps(context.ContextUser))
489
- .filter(a => a.Name !== options.AppName && a.Status !== 'Removed');
490
- const otherManifests = otherApps.flatMap(a => {
491
- try {
492
- return [JSON.parse(a.ManifestJSON)];
567
+ let teardownResult = { Success: true };
568
+ if (!options.KeepData) {
569
+ teardownResult = await HandleTeardown(manifest, context, existingApp.Subpath ?? undefined);
570
+ if (!teardownResult.Success) {
571
+ Callbacks?.OnError?.('Metadata', `Teardown failed: ${teardownResult.ErrorMessage}`);
572
+ }
493
573
  }
494
- catch {
495
- Callbacks?.OnWarn?.('Config', `Ignoring app '${a.Name}' when computing shared excludes — its ManifestJSON could not be parsed.`);
496
- return [];
574
+ let schemaDropError;
575
+ if (!options.KeepData && existingApp.SchemaName && !schemaShared) {
576
+ Callbacks?.OnProgress?.('Schema', `Dropping schema '${existingApp.SchemaName}'...`);
577
+ const dropResult = await DropAppSchema(existingApp.SchemaName, context.DatabaseProvider, {
578
+ allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema === true,
579
+ });
580
+ if (!dropResult.Success) {
581
+ schemaDropError = dropResult.ErrorMessage;
582
+ Callbacks?.OnError?.('Schema', `Failed to drop schema: ${dropResult.ErrorMessage}`);
583
+ }
497
584
  }
498
- });
499
- await Promise.all([
500
- Promise.resolve(RemoveServerDynamicPackages(context.RepoRoot, options.AppName, context.ServerPackagePath)),
501
- Promise.resolve(manifest.schema ? RemoveEntityPackageMapping(context.RepoRoot, manifest.schema.name, context.ServerPackagePath) : undefined),
502
- Promise.resolve(HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)),
503
- Promise.resolve(RemoveAppPackages({
504
- RepoRoot: context.RepoRoot,
505
- ServerPackages: manifest.packages?.server ?? [],
506
- ClientPackages: manifest.packages?.client ?? [],
507
- SharedPackages: manifest.packages?.shared ?? [],
508
- Version: existingApp.Version,
509
- ServerPackagePath: context.ServerPackagePath,
510
- ClientPackagePath: context.ClientPackagePath,
511
- PackageManager: context.PackageManager,
512
- AdditionalTargets: context.AdditionalTargets,
513
- })),
514
- ]);
515
- Callbacks?.OnProgress?.('Packages', 'Running package install...');
516
- const installResult = RunPackageInstall(context.RepoRoot, options.Verbose, undefined, context.PackageManager);
517
- if (!installResult.Success) {
518
- Callbacks?.OnWarn?.('Packages', `Package install warning during removal: ${installResult.ErrorMessage}`);
585
+ const removalErrors = [
586
+ shareCheckError,
587
+ teardownResult.Success ? undefined : teardownResult.ErrorMessage,
588
+ metadataResult.Success ? undefined : metadataResult.ErrorMessage,
589
+ schemaDropError,
590
+ ].filter((e) => !!e);
591
+ if (removalErrors.length > 0) {
592
+ const combined = removalErrors.join('; ');
593
+ await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
594
+ Success: false,
595
+ DurationSeconds: GetDurationSeconds(startTime),
596
+ StartedAt: new Date(startTime),
597
+ EndedAt: new Date(),
598
+ Summary: `Remove failed: ${combined}`,
599
+ });
600
+ await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
601
+ return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, combined);
602
+ }
603
+ await SetAppStep(context.ContextUser, existingApp.ID, 'DbCleanupDone');
604
+ }
605
+ if (!IsStepDone(REMOVE_STEP_ORDER, resumeCheckpoint, 'FilesRemoved')) {
606
+ Callbacks?.OnProgress?.('Config', 'Removing config, client bootstrap, and package references...');
607
+ const otherApps = (await ListInstalledApps(context.ContextUser))
608
+ .filter(a => a.Name !== options.AppName && a.Status !== 'Removed');
609
+ const otherManifests = otherApps.flatMap(a => {
610
+ try {
611
+ return [JSON.parse(a.ManifestJSON)];
612
+ }
613
+ catch {
614
+ Callbacks?.OnWarn?.('Config', `Ignoring app '${a.Name}' when computing shared excludes — its ManifestJSON could not be parsed.`);
615
+ return [];
616
+ }
617
+ });
618
+ await Promise.all([
619
+ Promise.resolve(RemoveServerDynamicPackages(context.RepoRoot, options.AppName, context.ServerPackagePath)),
620
+ Promise.resolve(manifest.schema ? RemoveEntityPackageMapping(context.RepoRoot, manifest.schema.name, context.ServerPackagePath) : undefined),
621
+ Promise.resolve(HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)),
622
+ Promise.resolve(RemoveAppPackages({
623
+ RepoRoot: context.RepoRoot,
624
+ ServerPackages: manifest.packages?.server ?? [],
625
+ ClientPackages: manifest.packages?.client ?? [],
626
+ SharedPackages: manifest.packages?.shared ?? [],
627
+ Version: existingApp.Version,
628
+ ServerPackagePath: context.ServerPackagePath,
629
+ ClientPackagePath: context.ClientPackagePath,
630
+ PackageManager: context.PackageManager,
631
+ AdditionalTargets: context.AdditionalTargets,
632
+ })),
633
+ ]);
634
+ Callbacks?.OnProgress?.('Packages', 'Running package install...');
635
+ const installResult = RunPackageInstall(context.RepoRoot, options.Verbose, undefined, context.PackageManager);
636
+ if (!installResult.Success) {
637
+ Callbacks?.OnWarn?.('Packages', `Package install warning during removal: ${installResult.ErrorMessage}`);
638
+ }
639
+ await SetAppStep(context.ContextUser, existingApp.ID, 'FilesRemoved');
519
640
  }
641
+ await UpdateAppRecord(context.ContextUser, existingApp.ID, {
642
+ Status: 'Removed',
643
+ LastCompletedStep: null,
644
+ LastCompletedStepTargetVersion: null,
645
+ });
520
646
  try {
521
647
  await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
522
648
  Success: true,
@@ -529,9 +655,6 @@ export async function RemoveApp(options, context) {
529
655
  catch (histErr) {
530
656
  Callbacks?.OnWarn?.('Record', `App removed, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
531
657
  }
532
- await UpdateAppRecord(context.ContextUser, existingApp.ID, {
533
- Status: 'Removed',
534
- });
535
658
  Callbacks?.OnSuccess?.('Remove', `Successfully removed ${options.AppName}`);
536
659
  return {
537
660
  Success: true,
@@ -565,6 +688,9 @@ export async function DisableApp(appName, context) {
565
688
  if (!app) {
566
689
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
567
690
  }
691
+ if (!SETTLED_STATUSES.has(app.Status)) {
692
+ return BuildMutexConflictResult('Install', appName, app.Version, startTime, app.Status, 'disable');
693
+ }
568
694
  const toggle = ToggleServerDynamicPackages(context.RepoRoot, appName, false, context.ServerPackagePath);
569
695
  if (!toggle.Success) {
570
696
  return BuildFailureResult('Install', appName, app.Version, 'Config', startTime, toggle.ErrorMessage ?? 'Failed to update dynamicPackages.server in mj.config.cjs');
@@ -585,6 +711,9 @@ export async function EnableApp(appName, context) {
585
711
  if (!app) {
586
712
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
587
713
  }
714
+ if (!SETTLED_STATUSES.has(app.Status)) {
715
+ return BuildMutexConflictResult('Install', appName, app.Version, startTime, app.Status, 'enable');
716
+ }
588
717
  const toggle = ToggleServerDynamicPackages(context.RepoRoot, appName, true, context.ServerPackagePath);
589
718
  if (!toggle.Success) {
590
719
  return BuildFailureResult('Install', appName, app.Version, 'Config', startTime, toggle.ErrorMessage ?? 'Failed to update dynamicPackages.server in mj.config.cjs');
@@ -607,6 +736,9 @@ async function ResolveDependencyChain(manifest, context) {
607
736
  const installedApps = await ListInstalledApps(context.ContextUser);
608
737
  const installedMap = {};
609
738
  for (const app of installedApps) {
739
+ if (app.Status !== 'Active' && app.Status !== 'Disabled') {
740
+ continue;
741
+ }
610
742
  installedMap[app.Name] = { Version: app.Version, Repository: app.RepositoryURL };
611
743
  }
612
744
  const dependencies = (manifest.dependencies ?? {});
@@ -922,63 +1054,88 @@ async function ExecuteHookModule(specifier, payload, repoRoot) {
922
1054
  throw new OpenAppPhaseError('Hooks', err instanceof Error ? err.message : String(err));
923
1055
  }
924
1056
  }
925
- export async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks, provider) {
1057
+ export async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks, provider, options) {
926
1058
  try {
927
1059
  const rv = new RunView();
928
1060
  const escaped = EscapeSqlString(schemaName);
1061
+ const declaredAppIds = (options?.DeclaredApplicationIds ?? [])
1062
+ .map((id) => NormalizeUUID(id))
1063
+ .filter((id) => !!id);
1064
+ const useFkGraph = !!options?.DatabaseProvider?.Dialect;
929
1065
  const md = (provider ?? new Metadata());
930
- const tg = await md.CreateTransactionGroup();
931
- const queueDeleteByFilterOrThrow = async (entityName, filter) => {
932
- const r = await QueueDeleteEntitiesByFilter(rv, contextUser, entityName, filter, tg);
933
- if (!r.Success) {
934
- throw new Error(r.ErrorMessage ?? `Failed to queue delete of ${entityName} records`);
935
- }
936
- };
937
1066
  const entityResult = await rv.RunView({
938
1067
  EntityName: 'MJ: Entities',
939
- ExtraFilter: `SchemaName = '${escaped}'`,
1068
+ ExtraFilter: `LOWER(SchemaName) = LOWER('${escaped}')`,
940
1069
  ResultType: 'entity_object',
941
1070
  }, contextUser);
942
1071
  if (!entityResult.Success) {
943
1072
  throw new Error(`Failed to query entities for schema '${schemaName}': ${entityResult.ErrorMessage}`);
944
1073
  }
945
1074
  if (entityResult.Results.length === 0) {
946
- await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
1075
+ const tg = await md.CreateTransactionGroup();
1076
+ const r = await QueueDeleteEntitiesByFilter(rv, contextUser, 'MJ: Schema Info', `LOWER(SchemaName) = LOWER('${escaped}')`, tg);
1077
+ if (!r.Success) {
1078
+ throw new Error(r.ErrorMessage ?? 'Failed to queue delete of MJ: Schema Info records');
1079
+ }
947
1080
  if (!(await tg.Submit())) {
948
1081
  throw new Error('Transaction failed: schema-info cleanup could not be committed');
949
1082
  }
1083
+ await DeleteAppOwnedApplications(rv, declaredAppIds, contextUser, callbacks);
950
1084
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
951
1085
  return { Success: true };
952
1086
  }
953
1087
  const entityIds = entityResult.Results.map((e) => e.ID);
954
1088
  const idList = entityIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
955
- const fieldResult = await rv.RunView({
956
- EntityName: 'MJ: Entity Fields',
957
- ExtraFilter: `EntityID IN (${idList})`,
958
- ResultType: 'entity_object',
959
- }, contextUser);
960
- if (!fieldResult.Success) {
961
- throw new Error(`Failed to query entity fields for schema '${schemaName}': ${fieldResult.ErrorMessage}`);
962
- }
963
- const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(f.ID)}'`).join(',');
964
1089
  const ownedAppIds = await FindAppOwnedApplications(rv, contextUser, entityIds, idList);
965
- if (fieldIdList.length > 0) {
966
- await queueDeleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
967
- }
968
- await queueDeleteByFilterOrThrow('MJ: Entity Permissions', `EntityID IN (${idList})`);
969
- await queueDeleteByFilterOrThrow('MJ: Application Entities', `EntityID IN (${idList})`);
970
- await queueDeleteByFilterOrThrow('MJ: Entity Settings', `EntityID IN (${idList})`);
971
- await queueDeleteByFilterOrThrow('MJ: Entity Relationships', `EntityID IN (${idList}) OR RelatedEntityID IN (${idList})`);
972
- await queueDeleteByFilterOrThrow('MJ: Entity Fields', `EntityID IN (${idList})`);
973
- for (const entity of entityResult.Results) {
974
- entity.TransactionGroup = tg;
975
- await entity.Delete();
976
- }
977
- await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
978
- if (!(await tg.Submit())) {
979
- throw new Error('Transaction failed: entity metadata cleanup could not be committed atomically');
980
- }
981
- await DeleteAppOwnedApplications(rv, ownedAppIds, contextUser, callbacks);
1090
+ if (useFkGraph) {
1091
+ const dbProvider = options.DatabaseProvider;
1092
+ const mjSchema = options?.MJCoreSchema ?? '__mj';
1093
+ const rootPredicate = buildRootDoomedPredicate(dbProvider.Dialect, schemaName);
1094
+ await RunFkGraphTeardown(dbProvider, mjSchema, rootPredicate, callbacks);
1095
+ }
1096
+ else {
1097
+ const tg = await md.CreateTransactionGroup();
1098
+ const queueDeleteByFilterOrThrow = async (entityName, filter) => {
1099
+ const r = await QueueDeleteEntitiesByFilter(rv, contextUser, entityName, filter, tg);
1100
+ if (!r.Success) {
1101
+ throw new Error(r.ErrorMessage ?? `Failed to queue delete of ${entityName} records`);
1102
+ }
1103
+ };
1104
+ const fieldResult = await rv.RunView({
1105
+ EntityName: 'MJ: Entity Fields',
1106
+ ExtraFilter: `EntityID IN (${idList})`,
1107
+ ResultType: 'entity_object',
1108
+ }, contextUser);
1109
+ if (!fieldResult.Success) {
1110
+ throw new Error(`Failed to query entity fields for schema '${schemaName}': ${fieldResult.ErrorMessage}`);
1111
+ }
1112
+ const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(f.ID)}'`).join(',');
1113
+ if (fieldIdList.length > 0) {
1114
+ await queueDeleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
1115
+ }
1116
+ await queueDeleteByFilterOrThrow('MJ: Entity Permissions', `EntityID IN (${idList})`);
1117
+ await queueDeleteByFilterOrThrow('MJ: Application Entities', `EntityID IN (${idList})`);
1118
+ await queueDeleteByFilterOrThrow('MJ: Entity Settings', `EntityID IN (${idList})`);
1119
+ await queueDeleteByFilterOrThrow('MJ: Entity Relationships', `EntityID IN (${idList}) OR RelatedEntityID IN (${idList})`);
1120
+ await queueDeleteByFilterOrThrow('MJ: Entity Fields', `EntityID IN (${idList})`);
1121
+ for (const entity of entityResult.Results) {
1122
+ entity.TransactionGroup = tg;
1123
+ await entity.Delete();
1124
+ }
1125
+ await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
1126
+ if (!(await tg.Submit())) {
1127
+ throw new Error('Transaction failed: entity metadata cleanup could not be committed atomically');
1128
+ }
1129
+ }
1130
+ const allAppIds = [];
1131
+ const appSeen = new Set();
1132
+ for (const id of ownedAppIds.map((x) => NormalizeUUID(x)).concat(declaredAppIds)) {
1133
+ if (id && !appSeen.has(id)) {
1134
+ appSeen.add(id);
1135
+ allAppIds.push(id);
1136
+ }
1137
+ }
1138
+ await DeleteAppOwnedApplications(rv, allAppIds, contextUser, callbacks);
982
1139
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
983
1140
  return { Success: true };
984
1141
  }
@@ -988,6 +1145,33 @@ export async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks
988
1145
  return { Success: false, ErrorMessage: `Failed to remove entity metadata for schema '${schemaName}': ${message}` };
989
1146
  }
990
1147
  }
1148
+ async function ExtractDeclaredApplicationIds(manifest, context, subpath) {
1149
+ if (!manifest.migrations || !manifest.schema) {
1150
+ return [];
1151
+ }
1152
+ let tempDir;
1153
+ try {
1154
+ tempDir = join(tmpdir(), `mj-app-${manifest.name}-appids-${Date.now()}`);
1155
+ mkdirSync(tempDir, { recursive: true });
1156
+ const download = await DownloadAppMigrations(manifest, context, tempDir, subpath);
1157
+ if (!download.Success) {
1158
+ return [];
1159
+ }
1160
+ return await extractApplicationIds(tempDir);
1161
+ }
1162
+ catch {
1163
+ return [];
1164
+ }
1165
+ finally {
1166
+ if (tempDir) {
1167
+ try {
1168
+ rmSync(tempDir, { recursive: true, force: true });
1169
+ }
1170
+ catch {
1171
+ }
1172
+ }
1173
+ }
1174
+ }
991
1175
  async function FindAppOwnedApplications(rv, contextUser, entityIds, idList) {
992
1176
  const ours = new Set(entityIds.map((id) => NormalizeUUID(id)));
993
1177
  const linked = await rv.RunView({ EntityName: 'MJ: Application Entities', ExtraFilter: `EntityID IN (${idList})`, Fields: ['ApplicationID'], ResultType: 'simple' }, contextUser);