@memberjunction/open-app-engine 5.41.0 → 5.43.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 (48) hide show
  1. package/dist/dependency/dependency-graph-builder.d.ts +1 -1
  2. package/dist/dependency/dependency-graph-builder.d.ts.map +1 -1
  3. package/dist/dependency/dependency-graph-builder.js +5 -3
  4. package/dist/dependency/dependency-graph-builder.js.map +1 -1
  5. package/dist/dependency/dependency-resolver.d.ts +1 -0
  6. package/dist/dependency/dependency-resolver.d.ts.map +1 -1
  7. package/dist/dependency/dependency-resolver.js.map +1 -1
  8. package/dist/github/github-client.d.ts +10 -5
  9. package/dist/github/github-client.d.ts.map +1 -1
  10. package/dist/github/github-client.js +110 -94
  11. package/dist/github/github-client.js.map +1 -1
  12. package/dist/index.d.ts +2 -4
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -3
  15. package/dist/index.js.map +1 -1
  16. package/dist/install/config-manager.d.ts +2 -1
  17. package/dist/install/config-manager.d.ts.map +1 -1
  18. package/dist/install/config-manager.js +175 -28
  19. package/dist/install/config-manager.js.map +1 -1
  20. package/dist/install/history-recorder.d.ts +14 -3
  21. package/dist/install/history-recorder.d.ts.map +1 -1
  22. package/dist/install/history-recorder.js +82 -48
  23. package/dist/install/history-recorder.js.map +1 -1
  24. package/dist/install/install-orchestrator.d.ts +9 -2
  25. package/dist/install/install-orchestrator.d.ts.map +1 -1
  26. package/dist/install/install-orchestrator.js +348 -146
  27. package/dist/install/install-orchestrator.js.map +1 -1
  28. package/dist/install/migration-runner.d.ts +27 -0
  29. package/dist/install/migration-runner.d.ts.map +1 -1
  30. package/dist/install/migration-runner.js +29 -8
  31. package/dist/install/migration-runner.js.map +1 -1
  32. package/dist/install/package-manager.d.ts.map +1 -1
  33. package/dist/install/package-manager.js +51 -20
  34. package/dist/install/package-manager.js.map +1 -1
  35. package/dist/install/schema-manager.d.ts.map +1 -1
  36. package/dist/install/schema-manager.js +17 -12
  37. package/dist/install/schema-manager.js.map +1 -1
  38. package/dist/manifest/manifest-schema.d.ts +53 -6
  39. package/dist/manifest/manifest-schema.d.ts.map +1 -1
  40. package/dist/manifest/manifest-schema.js +20 -7
  41. package/dist/manifest/manifest-schema.js.map +1 -1
  42. package/dist/types/open-app-types.d.ts +3 -0
  43. package/dist/types/open-app-types.d.ts.map +1 -1
  44. package/package.json +6 -3
  45. package/dist/install/client-bootstrap-gen.d.ts +0 -8
  46. package/dist/install/client-bootstrap-gen.d.ts.map +0 -1
  47. package/dist/install/client-bootstrap-gen.js +0 -38
  48. package/dist/install/client-bootstrap-gen.js.map +0 -1
@@ -1,34 +1,36 @@
1
1
  import { tmpdir } from 'node:os';
2
2
  import { join } from 'node:path';
3
- import { mkdirSync } from 'node:fs';
3
+ import { mkdirSync, readFileSync } from 'node:fs';
4
4
  import { ParseAndValidateManifest } from '../manifest/manifest-loader.js';
5
5
  import { CheckMJVersionCompatibility, IsValidUpgrade } from '../dependency/version-checker.js';
6
6
  import { ResolveDependencyGraph } from '../dependency/dependency-graph-builder.js';
7
- import { FetchManifestFromGitHub, DownloadMigrations, GetLatestVersion, ValidateGitHubTag } from '../github/github-client.js';
7
+ import { FetchManifestFromGitHub, DownloadMigrations, GetLatestVersion, ListGitHubReleases, ListGitHubTags, ValidateGitHubTag, ParseGitHubUrl } from '../github/github-client.js';
8
+ import semver from 'semver';
8
9
  import { CreateAppSchema, DropAppSchema, SchemaExists, EscapeSqlString } from './schema-manager.js';
9
10
  import { RunAppMigrations } from './migration-runner.js';
10
11
  import { AddAppPackages, RemoveAppPackages, RunPackageInstall, BumpPrefixedDependencies } from './package-manager.js';
11
- import { AddServerDynamicPackages, RemoveServerDynamicPackages, ToggleServerDynamicPackages, AddEntityPackageMapping, RemoveEntityPackageMapping } from './config-manager.js';
12
+ import { AddServerDynamicPackages, AddClientDynamicPackages, RemoveServerDynamicPackages, ToggleServerDynamicPackages, AddEntityPackageMapping, RemoveEntityPackageMapping } from './config-manager.js';
12
13
  import { AngularConfigManager } from './angular-config-manager.js';
13
- import { RegenerateClientBootstrap } from './client-bootstrap-gen.js';
14
14
  import { Metadata, RunView } from '@memberjunction/core';
15
- import { RecordAppInstallation, RecordInstallHistoryEntry, RecordAppDependencies, DeleteAppDependencies, SetAppStatus, FindInstalledApp, FindDependentApps, ListInstalledApps, UpdateAppRecord, } from './history-recorder.js';
15
+ import { NormalizeUUID } from '@memberjunction/global';
16
+ import { RecordAppInstallation, RecordInstallHistoryEntry, RecordAppDependencies, ReplaceAppDependenciesAtomically, SetAppStatus, FindInstalledApp, CheckSchemaSharedByOtherApps, FindDependentApps, ListInstalledApps, UpdateAppRecord, } from './history-recorder.js';
16
17
  export async function InstallApp(options, context) {
17
18
  const startTime = Date.now();
18
19
  const { Callbacks } = context;
19
20
  let createdAppId;
20
21
  let manifest;
22
+ const subpath = options.Subpath ?? ParseGitHubUrl(options.Source)?.Subpath;
21
23
  try {
22
24
  const explicitVersion = options.Version;
23
25
  if (explicitVersion) {
24
26
  Callbacks?.OnProgress?.('Fetch', `Validating version tag v${explicitVersion.replace(/^v/, '')} exists...`);
25
- const tagResult = await ValidateGitHubTag(options.Source, explicitVersion, context.GitHubOptions);
27
+ const tagResult = await ValidateGitHubTag(options.Source, explicitVersion, context.GitHubOptions, subpath);
26
28
  if (!tagResult.Exists) {
27
29
  return BuildFailureResult('Install', options.Source, '', 'Schema', startTime, tagResult.ErrorMessage ?? `Version ${explicitVersion} not found`);
28
30
  }
29
31
  }
30
32
  Callbacks?.OnProgress?.('Fetch', `Fetching manifest from ${options.Source}...`);
31
- const fetchResult = await FetchManifestFromGitHub(options.Source, explicitVersion, context.GitHubOptions);
33
+ const fetchResult = await FetchManifestFromGitHub(options.Source, explicitVersion, context.GitHubOptions, subpath);
32
34
  if (!fetchResult.Success || !fetchResult.ManifestJSON) {
33
35
  return BuildFailureResult('Install', options.Source, '', 'Schema', startTime, fetchResult.ErrorMessage ?? 'Failed to fetch manifest');
34
36
  }
@@ -61,7 +63,7 @@ export async function InstallApp(options, context) {
61
63
  }
62
64
  }
63
65
  const existingApp = await FindInstalledApp(context.ContextUser, manifest.name);
64
- const isReinstall = existingApp != null && existingApp.Status === 'Removed';
66
+ const isReinstall = existingApp != null && (existingApp.Status === 'Removed' || existingApp.Status === 'Error');
65
67
  if (existingApp && !isReinstall) {
66
68
  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.`);
67
69
  }
@@ -72,24 +74,24 @@ export async function InstallApp(options, context) {
72
74
  if (!schemaResult.Success) {
73
75
  return BuildFailureResult('Install', manifest.name, manifest.version, 'Schema', startTime, schemaResult.ErrorMessage ?? 'Schema creation failed');
74
76
  }
75
- schemaCreated = !isReinstall;
77
+ schemaCreated = schemaResult.Created === true;
76
78
  }
77
79
  if (manifest.migrations && manifest.schema) {
78
- const migrationResult = await HandleMigrations(manifest, context);
80
+ const migrationResult = await HandleMigrations(manifest, context, subpath);
79
81
  if (!migrationResult.Success) {
80
82
  await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
81
83
  return BuildFailureResult('Install', manifest.name, manifest.version, 'Migration', startTime, migrationResult.ErrorMessage ?? 'Migration failed');
82
84
  }
83
85
  }
84
86
  Callbacks?.OnProgress?.('Record', 'Recording app installation...');
85
- const recordResult = await RecordInstallationAtomically(context.ContextUser, manifest, Callbacks);
87
+ const recordResult = await RecordInstallationAtomically(context.ContextUser, manifest, Callbacks, undefined, subpath);
86
88
  if (!recordResult.Success) {
87
89
  await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
88
90
  return BuildFailureResult('Install', manifest.name, manifest.version, 'Record', startTime, recordResult.ErrorMessage ?? 'Failed to record installation');
89
91
  }
90
92
  createdAppId = recordResult.AppId;
91
93
  Callbacks?.OnProgress?.('Config', 'Updating configuration files...');
92
- const pkgResult = await HandlePackageInstallation(manifest, context, effectivePackageVersion, effectiveVersionStrategy);
94
+ const pkgResult = await HandlePackageInstallation(manifest, context, effectivePackageVersion, effectiveVersionStrategy, options.Verbose);
93
95
  let npmInstallWarning;
94
96
  if (!pkgResult.Success) {
95
97
  if (pkgResult.PackageJsonUpdated) {
@@ -110,24 +112,38 @@ export async function InstallApp(options, context) {
110
112
  }
111
113
  HandleAngularPrebundleExcludes(manifest, context);
112
114
  Callbacks?.OnProgress?.('Record', 'Finalizing installation...');
113
- await SetAppStatus(context.ContextUser, createdAppId, 'Active');
114
- await HandleClientBootstrapRegeneration(context);
115
+ const finalStatus = npmInstallWarning ? 'Disabled' : 'Active';
116
+ await SetAppStatus(context.ContextUser, createdAppId, finalStatus);
117
+ if (finalStatus !== 'Active') {
118
+ ToggleServerDynamicPackages(context.RepoRoot, manifest.name, false);
119
+ }
115
120
  if (manifest.hooks?.postInstall) {
116
121
  Callbacks?.OnProgress?.('Hooks', 'Running postInstall hook...');
117
122
  await ExecuteHook(manifest.hooks.postInstall, context.RepoRoot);
118
123
  }
119
- await RecordInstallHistoryEntry(context.ContextUser, createdAppId, 'Install', manifest, {
120
- Success: true,
121
- DurationSeconds: GetDurationSeconds(startTime),
122
- StartedAt: new Date(startTime),
123
- EndedAt: new Date(),
124
- Summary: 'Initial installation',
125
- });
124
+ try {
125
+ await RecordInstallHistoryEntry(context.ContextUser, createdAppId, 'Install', manifest, {
126
+ Success: true,
127
+ DurationSeconds: GetDurationSeconds(startTime),
128
+ StartedAt: new Date(startTime),
129
+ EndedAt: new Date(),
130
+ Summary: 'Initial installation',
131
+ });
132
+ }
133
+ catch (histErr) {
134
+ Callbacks?.OnWarn?.('Record', `App installed, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
135
+ }
126
136
  Callbacks?.OnSuccess?.('Install', `Successfully installed ${manifest.name} v${manifest.version}`);
127
- const baseSummary = 'App installed successfully. Restart MJAPI and rebuild MJExplorer to activate.';
128
- const summary = npmInstallWarning
129
- ? `${baseSummary}\n\n⚠ npm install failed — package.json and config files were updated but dependencies were not installed. Log in to npm ('npm login') or configure your .npmrc, then run 'npm install' to complete the setup.`
130
- : baseSummary;
137
+ let summary;
138
+ if (npmInstallWarning) {
139
+ summary = `App installed but left DISABLED — npm install failed, so its packages are not resolved. package.json and config were updated; log in to npm ('npm login') or fix your .npmrc, run 'npm install', then 'mj app enable ${manifest.name}'.`;
140
+ }
141
+ else if (manifest.schema) {
142
+ summary = `App installed. Its schema and tables exist, but entity metadata is generated by CodeGen — run 'mj codegen', then restart MJAPI and rebuild MJExplorer to activate.`;
143
+ }
144
+ else {
145
+ summary = 'App installed successfully. Restart MJAPI and rebuild MJExplorer to activate.';
146
+ }
131
147
  return {
132
148
  Success: true,
133
149
  Action: 'Install',
@@ -151,11 +167,11 @@ export async function InstallApp(options, context) {
151
167
  return BuildFailureResult('Install', options.Source, '', 'Schema', startTime, message);
152
168
  }
153
169
  }
154
- async function RecordInstallationAtomically(contextUser, manifest, callbacks, provider) {
170
+ async function RecordInstallationAtomically(contextUser, manifest, callbacks, provider, subpath) {
155
171
  const md = (provider ?? new Metadata());
156
172
  const tg = await md.CreateTransactionGroup();
157
173
  try {
158
- const appId = await RecordAppInstallation(contextUser, manifest, callbacks, tg, 'Installing');
174
+ const appId = await RecordAppInstallation(contextUser, manifest, callbacks, tg, 'Installing', provider, subpath);
159
175
  if (manifest.dependencies) {
160
176
  await RecordAppDependencies(contextUser, appId, manifest.dependencies, tg);
161
177
  }
@@ -199,12 +215,12 @@ export async function UpgradeApp(options, context) {
199
215
  upgradeAppId = existingApp.ID;
200
216
  previousVersion = existingApp.Version;
201
217
  const explicitUpgradeVersion = options.Version;
202
- const targetVersion = explicitUpgradeVersion ?? (await GetLatestVersion(existingApp.RepositoryURL, context.GitHubOptions));
218
+ const targetVersion = explicitUpgradeVersion ?? (await GetLatestVersion(existingApp.RepositoryURL, context.GitHubOptions, existingApp.Subpath ?? undefined));
203
219
  if (!targetVersion) {
204
220
  return BuildFailureResult('Upgrade', options.AppName, '', 'Schema', startTime, 'Could not determine target version');
205
221
  }
206
222
  if (explicitUpgradeVersion) {
207
- const tagResult = await ValidateGitHubTag(existingApp.RepositoryURL, targetVersion, context.GitHubOptions);
223
+ const tagResult = await ValidateGitHubTag(existingApp.RepositoryURL, targetVersion, context.GitHubOptions, existingApp.Subpath ?? undefined);
208
224
  if (!tagResult.Exists) {
209
225
  return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Schema', startTime, tagResult.ErrorMessage ?? `Version ${targetVersion} not found`);
210
226
  }
@@ -223,8 +239,9 @@ export async function UpgradeApp(options, context) {
223
239
  Summary: upgradeCheck.Message ?? `Already at version ${targetVersion}`,
224
240
  };
225
241
  }
242
+ const subpath = existingApp.Subpath ?? undefined;
226
243
  Callbacks?.OnProgress?.('Fetch', `Fetching manifest for ${options.AppName} v${targetVersion}...`);
227
- const fetchResult = await FetchManifestFromGitHub(existingApp.RepositoryURL, targetVersion, context.GitHubOptions);
244
+ const fetchResult = await FetchManifestFromGitHub(existingApp.RepositoryURL, targetVersion, context.GitHubOptions, subpath);
228
245
  if (!fetchResult.Success || !fetchResult.ManifestJSON) {
229
246
  return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Schema', startTime, fetchResult.ErrorMessage ?? 'Failed to fetch manifest');
230
247
  }
@@ -254,16 +271,21 @@ export async function UpgradeApp(options, context) {
254
271
  }
255
272
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Upgrading');
256
273
  if (manifest.migrations && manifest.schema) {
257
- const migrationResult = await HandleMigrations(manifest, context);
274
+ const migrationResult = await HandleMigrations(manifest, context, subpath);
258
275
  if (!migrationResult.Success) {
259
- await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Migration', migrationResult.ErrorMessage ?? 'Migration failed', startTime, previousVersion);
276
+ const detail = migrationResult.ErrorMessage ?? 'Migration failed';
277
+ const recoverable = `${detail}\n\nThe schema may be partially upgraded (migrations are forward-only; there is no ` +
278
+ `automatic rollback). Skyway tracks applied migrations, so once the cause is fixed, re-run ` +
279
+ `'mj app upgrade ${options.AppName}' to resume from the last successful migration. If the schema ` +
280
+ `is left inconsistent, restore it from a backup taken before the upgrade.`;
281
+ await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Migration', recoverable, startTime, previousVersion);
260
282
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
261
- return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Migration', startTime, migrationResult.ErrorMessage ?? 'Migration failed');
283
+ return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Migration', startTime, recoverable);
262
284
  }
263
285
  }
264
286
  const effectiveUpgradeVersion = explicitUpgradeVersion ? targetVersion.replace(/^v/, '') : manifest.version;
265
287
  const effectiveUpgradeStrategy = explicitUpgradeVersion ? 'exact' : context.VersionStrategy;
266
- const pkgResult = await HandlePackageInstallation(manifest, context, effectiveUpgradeVersion, effectiveUpgradeStrategy);
288
+ const pkgResult = await HandlePackageInstallation(manifest, context, effectiveUpgradeVersion, effectiveUpgradeStrategy, options.Verbose);
267
289
  let npmInstallWarning;
268
290
  if (!pkgResult.Success) {
269
291
  if (pkgResult.PackageJsonUpdated) {
@@ -288,23 +310,29 @@ export async function UpgradeApp(options, context) {
288
310
  ManifestJSON: JSON.stringify(manifest),
289
311
  Status: 'Active',
290
312
  });
291
- await HandleClientBootstrapRegeneration(context);
292
313
  if (manifest.hooks?.postUpgrade) {
293
314
  Callbacks?.OnProgress?.('Hooks', 'Running postUpgrade hook...');
294
315
  await ExecuteHook(manifest.hooks.postUpgrade, context.RepoRoot);
295
316
  }
296
317
  if (manifest.dependencies) {
297
- await DeleteAppDependencies(context.ContextUser, existingApp.ID);
298
- await RecordAppDependencies(context.ContextUser, existingApp.ID, manifest.dependencies);
318
+ const depsReplaced = await ReplaceAppDependenciesAtomically(context.ContextUser, existingApp.ID, manifest.dependencies);
319
+ if (!depsReplaced) {
320
+ Callbacks?.OnWarn?.('Record', 'App upgraded, but its dependency records could not be updated atomically — re-run the upgrade to refresh them.');
321
+ }
322
+ }
323
+ try {
324
+ await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Upgrade', manifest, {
325
+ PreviousVersion: previousVersion,
326
+ Success: true,
327
+ DurationSeconds: GetDurationSeconds(startTime),
328
+ StartedAt: new Date(startTime),
329
+ EndedAt: new Date(),
330
+ Summary: `Upgraded from ${previousVersion} to ${manifest.version}`,
331
+ });
332
+ }
333
+ catch (histErr) {
334
+ Callbacks?.OnWarn?.('Record', `App upgraded, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
299
335
  }
300
- await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Upgrade', manifest, {
301
- PreviousVersion: previousVersion,
302
- Success: true,
303
- DurationSeconds: GetDurationSeconds(startTime),
304
- StartedAt: new Date(startTime),
305
- EndedAt: new Date(),
306
- Summary: `Upgraded from ${previousVersion} to ${manifest.version}`,
307
- });
308
336
  Callbacks?.OnSuccess?.('Upgrade', `Successfully upgraded ${options.AppName} to v${manifest.version}`);
309
337
  const baseSummary = `Upgraded from ${previousVersion} to ${manifest.version}. Restart MJAPI and rebuild MJExplorer.`;
310
338
  const summary = npmInstallWarning
@@ -359,15 +387,73 @@ export async function RemoveApp(options, context) {
359
387
  Callbacks?.OnProgress?.('Hooks', 'Running preRemove hook...');
360
388
  await ExecuteHook(manifest.hooks.preRemove, context.RepoRoot);
361
389
  }
390
+ const shareCheck = existingApp.SchemaName
391
+ ? await CheckSchemaSharedByOtherApps(context.ContextUser, existingApp.SchemaName, existingApp.ID)
392
+ : { Shared: false, CheckFailed: false };
393
+ const schemaShared = shareCheck.Shared;
394
+ const shareCheckError = shareCheck.CheckFailed
395
+ ? `Could not determine whether schema '${existingApp.SchemaName}' is shared by other Open Apps (share-check query failed): ${shareCheck.ErrorMessage ?? 'unknown error'}`
396
+ : undefined;
397
+ if (schemaShared && !shareCheck.CheckFailed) {
398
+ Callbacks?.OnWarn?.('Schema', `Schema '${existingApp.SchemaName}' is still used by another installed Open App — skipping metadata + schema removal to protect co-tenant data.`);
399
+ }
400
+ let metadataResult = { Success: true };
401
+ if (existingApp.SchemaName && !schemaShared) {
402
+ Callbacks?.OnProgress?.('Metadata', `Removing entity metadata for schema '${existingApp.SchemaName}'...`);
403
+ metadataResult = await RemoveAppEntityMetadata(existingApp.SchemaName, context.ContextUser, Callbacks);
404
+ }
405
+ let teardownResult = { Success: true };
406
+ if (!options.KeepData) {
407
+ teardownResult = await HandleTeardown(manifest, context, existingApp.Subpath ?? undefined);
408
+ if (!teardownResult.Success) {
409
+ Callbacks?.OnError?.('Metadata', `Teardown failed: ${teardownResult.ErrorMessage}`);
410
+ }
411
+ }
412
+ let schemaDropError;
413
+ if (!options.KeepData && existingApp.SchemaName && !schemaShared) {
414
+ Callbacks?.OnProgress?.('Schema', `Dropping schema '${existingApp.SchemaName}'...`);
415
+ const dropResult = await DropAppSchema(existingApp.SchemaName, context.DatabaseProvider, {
416
+ allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema === true,
417
+ });
418
+ if (!dropResult.Success) {
419
+ schemaDropError = dropResult.ErrorMessage;
420
+ Callbacks?.OnError?.('Schema', `Failed to drop schema: ${dropResult.ErrorMessage}`);
421
+ }
422
+ }
423
+ const removalErrors = [
424
+ shareCheckError,
425
+ teardownResult.Success ? undefined : teardownResult.ErrorMessage,
426
+ metadataResult.Success ? undefined : metadataResult.ErrorMessage,
427
+ schemaDropError,
428
+ ].filter((e) => !!e);
429
+ if (removalErrors.length > 0) {
430
+ const combined = removalErrors.join('; ');
431
+ await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
432
+ Success: false,
433
+ DurationSeconds: GetDurationSeconds(startTime),
434
+ StartedAt: new Date(startTime),
435
+ EndedAt: new Date(),
436
+ Summary: `Remove failed: ${combined}`,
437
+ });
438
+ await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
439
+ return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, combined);
440
+ }
362
441
  Callbacks?.OnProgress?.('Config', 'Removing config, client bootstrap, and package references...');
363
442
  const otherApps = (await ListInstalledApps(context.ContextUser))
364
443
  .filter(a => a.Name !== options.AppName && a.Status !== 'Removed');
365
- const otherManifests = otherApps.map(a => JSON.parse(a.ManifestJSON));
444
+ const otherManifests = otherApps.flatMap(a => {
445
+ try {
446
+ return [JSON.parse(a.ManifestJSON)];
447
+ }
448
+ catch {
449
+ Callbacks?.OnWarn?.('Config', `Ignoring app '${a.Name}' when computing shared excludes — its ManifestJSON could not be parsed.`);
450
+ return [];
451
+ }
452
+ });
366
453
  await Promise.all([
367
454
  Promise.resolve(RemoveServerDynamicPackages(context.RepoRoot, options.AppName)),
368
455
  Promise.resolve(manifest.schema ? RemoveEntityPackageMapping(context.RepoRoot, manifest.schema.name) : undefined),
369
456
  Promise.resolve(HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)),
370
- HandleClientBootstrapRegeneration(context),
371
457
  Promise.resolve(RemoveAppPackages({
372
458
  RepoRoot: context.RepoRoot,
373
459
  ServerPackages: manifest.packages?.server ?? [],
@@ -385,26 +471,18 @@ export async function RemoveApp(options, context) {
385
471
  if (!installResult.Success) {
386
472
  Callbacks?.OnWarn?.('Packages', `Package install warning during removal: ${installResult.ErrorMessage}`);
387
473
  }
388
- if (existingApp.SchemaName) {
389
- Callbacks?.OnProgress?.('Metadata', `Removing entity metadata for schema '${existingApp.SchemaName}'...`);
390
- await RemoveAppEntityMetadata(existingApp.SchemaName, context.ContextUser, Callbacks);
391
- }
392
- if (!options.KeepData && existingApp.SchemaName) {
393
- Callbacks?.OnProgress?.('Schema', `Dropping schema '${existingApp.SchemaName}'...`);
394
- const dropResult = await DropAppSchema(existingApp.SchemaName, context.DatabaseProvider, {
395
- allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema === true,
474
+ try {
475
+ await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
476
+ Success: true,
477
+ DurationSeconds: GetDurationSeconds(startTime),
478
+ StartedAt: new Date(startTime),
479
+ EndedAt: new Date(),
480
+ Summary: options.KeepData ? 'Removed (data kept)' : 'Removed (data dropped)',
396
481
  });
397
- if (!dropResult.Success) {
398
- Callbacks?.OnWarn?.('Schema', `Failed to drop schema: ${dropResult.ErrorMessage}`);
399
- }
400
482
  }
401
- await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
402
- Success: true,
403
- DurationSeconds: GetDurationSeconds(startTime),
404
- StartedAt: new Date(startTime),
405
- EndedAt: new Date(),
406
- Summary: options.KeepData ? 'Removed (data kept)' : 'Removed (data dropped)',
407
- });
483
+ catch (histErr) {
484
+ Callbacks?.OnWarn?.('Record', `App removed, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
485
+ }
408
486
  await UpdateAppRecord(context.ContextUser, existingApp.ID, {
409
487
  Status: 'Removed',
410
488
  });
@@ -440,9 +518,11 @@ export async function DisableApp(appName, context) {
440
518
  if (!app) {
441
519
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
442
520
  }
443
- ToggleServerDynamicPackages(context.RepoRoot, appName, false);
521
+ const toggle = ToggleServerDynamicPackages(context.RepoRoot, appName, false);
522
+ if (!toggle.Success) {
523
+ return BuildFailureResult('Install', appName, app.Version, 'Config', startTime, toggle.ErrorMessage ?? 'Failed to update dynamicPackages.server in mj.config.cjs');
524
+ }
444
525
  await SetAppStatus(context.ContextUser, app.ID, 'Disabled');
445
- await HandleClientBootstrapRegeneration(context);
446
526
  return {
447
527
  Success: true,
448
528
  Action: 'Install',
@@ -458,9 +538,11 @@ export async function EnableApp(appName, context) {
458
538
  if (!app) {
459
539
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
460
540
  }
461
- ToggleServerDynamicPackages(context.RepoRoot, appName, true);
541
+ const toggle = ToggleServerDynamicPackages(context.RepoRoot, appName, true);
542
+ if (!toggle.Success) {
543
+ return BuildFailureResult('Install', appName, app.Version, 'Config', startTime, toggle.ErrorMessage ?? 'Failed to update dynamicPackages.server in mj.config.cjs');
544
+ }
462
545
  await SetAppStatus(context.ContextUser, app.ID, 'Active');
463
- await HandleClientBootstrapRegeneration(context);
464
546
  return {
465
547
  Success: true,
466
548
  Action: 'Install',
@@ -492,12 +574,12 @@ async function ResolveDependencyChain(manifest, context) {
492
574
  }
493
575
  const depsToInstall = (result.InstallOrder ?? [])
494
576
  .filter((d) => !d.AlreadyInstalled)
495
- .map((d) => ({ AppName: d.AppName, Repository: d.Repository, VersionRange: d.VersionRange }));
577
+ .map((d) => ({ AppName: d.AppName, Repository: d.Repository, VersionRange: d.VersionRange, Subpath: d.Subpath }));
496
578
  return { Success: true, DepsToInstall: depsToInstall };
497
579
  }
498
580
  function BuildManifestFetcher(context) {
499
- return async (repoUrl) => {
500
- const fetched = await FetchManifestFromGitHub(repoUrl, undefined, context.GitHubOptions);
581
+ return async (repoUrl, subpath) => {
582
+ const fetched = await FetchManifestFromGitHub(repoUrl, undefined, context.GitHubOptions, subpath);
501
583
  if (!fetched.Success || !fetched.ManifestJSON) {
502
584
  return { Success: false, ErrorMessage: fetched.ErrorMessage ?? 'Failed to fetch manifest' };
503
585
  }
@@ -524,8 +606,14 @@ async function InstallDependencies(deps, context, inherited) {
524
606
  };
525
607
  }
526
608
  context.Callbacks?.OnProgress?.('Dependencies', `Installing dependency from ${dep.Repository}...`);
609
+ const resolved = await ResolveDependencyVersion(dep.Repository, dep.VersionRange, context.GitHubOptions);
610
+ if (resolved.ErrorMessage) {
611
+ return { Success: false, ErrorMessage: `Cannot install dependency '${dep.AppName}': ${resolved.ErrorMessage}` };
612
+ }
527
613
  const result = await InstallApp({
528
614
  Source: dep.Repository,
615
+ Subpath: dep.Subpath,
616
+ Version: resolved.Version,
529
617
  _skipDependencyResolution: true,
530
618
  AllowDoubleUnderscoreSchema: inherited.AllowDoubleUnderscoreSchema,
531
619
  Verbose: inherited.Verbose,
@@ -536,6 +624,35 @@ async function InstallDependencies(deps, context, inherited) {
536
624
  }
537
625
  return { Success: true };
538
626
  }
627
+ export async function ResolveDependencyVersion(repoUrl, versionRange, options) {
628
+ const range = (versionRange ?? '').trim();
629
+ if (range === '' || range === '*' || range.toLowerCase() === 'latest') {
630
+ return { Version: undefined };
631
+ }
632
+ if (semver.valid(range)) {
633
+ return { Version: range.replace(/^v/, '') };
634
+ }
635
+ if (!semver.validRange(range)) {
636
+ return { ErrorMessage: `version '${versionRange}' is not a valid semver version or range` };
637
+ }
638
+ const [releases, tags] = await Promise.all([
639
+ ListGitHubReleases(repoUrl, options),
640
+ ListGitHubTags(repoUrl, options),
641
+ ]);
642
+ const candidates = [
643
+ ...releases.filter((r) => !r.Draft).map((r) => r.TagName.replace(/^v/, '')),
644
+ ...tags.map((t) => t.replace(/^v/, '')),
645
+ ].filter((v) => semver.valid(v) != null);
646
+ if (candidates.length === 0) {
647
+ return { ErrorMessage: `no published versions found at ${repoUrl} to satisfy '${versionRange}'` };
648
+ }
649
+ const best = semver.maxSatisfying(candidates, range);
650
+ if (!best) {
651
+ const latest = candidates.sort(semver.rcompare)[0];
652
+ return { ErrorMessage: `no published version at ${repoUrl} satisfies '${versionRange}' (latest available: ${latest})` };
653
+ }
654
+ return { Version: best };
655
+ }
539
656
  async function HandleSchemaCreation(manifest, context, isReinstall = false, allowDoubleUnderscore = false) {
540
657
  if (!manifest.schema) {
541
658
  return { Success: true };
@@ -545,25 +662,26 @@ async function HandleSchemaCreation(manifest, context, isReinstall = false, allo
545
662
  if (exists) {
546
663
  if (isReinstall || manifest.schema.createIfNotExists !== false) {
547
664
  context.Callbacks?.OnProgress?.('Schema', `Reusing existing schema '${manifest.schema.name}'`);
548
- return { Success: true };
665
+ return { Success: true, Created: false };
549
666
  }
550
667
  return { Success: false, ErrorMessage: `Schema '${manifest.schema.name}' already exists` };
551
668
  }
552
669
  if (manifest.schema.createIfNotExists !== false) {
553
670
  context.Callbacks?.OnProgress?.('Schema', `Creating schema '${manifest.schema.name}'...`);
554
671
  const result = await CreateAppSchema(manifest.schema.name, context.DatabaseProvider, { allowDoubleUnderscore });
555
- return { Success: result.Success, ErrorMessage: result.ErrorMessage };
672
+ return { Success: result.Success, ErrorMessage: result.ErrorMessage, Created: result.Success };
556
673
  }
557
674
  return { Success: false, ErrorMessage: `Schema '${manifest.schema.name}' does not exist and createIfNotExists is false` };
558
675
  }
559
- async function HandleMigrations(manifest, context) {
676
+ async function HandleMigrations(manifest, context, subpath) {
560
677
  if (!manifest.schema || !manifest.migrations) {
561
678
  return { Success: true };
562
679
  }
563
680
  context.Callbacks?.OnProgress?.('Migration', 'Downloading migration files...');
564
681
  const tempDir = join(tmpdir(), `mj-app-${manifest.name}-${Date.now()}`);
565
682
  mkdirSync(tempDir, { recursive: true });
566
- const downloadResult = await DownloadMigrations(manifest.repository, manifest.version, manifest.migrations.directory, tempDir, context.GitHubOptions);
683
+ const platform = context.DatabaseProvider.Dialect.PlatformKey;
684
+ const downloadResult = await DownloadAppMigrations(manifest, context, tempDir, subpath);
567
685
  if (!downloadResult.Success) {
568
686
  return { Success: false, ErrorMessage: downloadResult.ErrorMessage };
569
687
  }
@@ -574,10 +692,62 @@ async function HandleMigrations(manifest, context) {
574
692
  DatabaseConfig: context.DatabaseConfig,
575
693
  MJCoreSchema: context.MJCoreSchema,
576
694
  ExtraPlaceholders: context.MigrationPlaceholders,
695
+ Platform: platform,
577
696
  });
578
697
  return { Success: migrationResult.Success, ErrorMessage: migrationResult.ErrorMessage };
579
698
  }
580
- async function HandlePackageInstallation(manifest, context, packageVersion, versionStrategy) {
699
+ async function HandleTeardown(manifest, context, subpath) {
700
+ const teardownDir = manifest.migrations?.teardownDirectory;
701
+ if (!teardownDir) {
702
+ return { Success: true };
703
+ }
704
+ const platform = context.DatabaseProvider.Dialect.PlatformKey;
705
+ const dir = platform === 'postgresql' ? `${teardownDir}-pg` : teardownDir;
706
+ const tempDir = join(tmpdir(), `mj-app-${manifest.name}-teardown-${Date.now()}`);
707
+ mkdirSync(tempDir, { recursive: true });
708
+ context.Callbacks?.OnProgress?.('Metadata', 'Downloading teardown scripts...');
709
+ const download = await DownloadMigrations(manifest.repository, manifest.version, dir, tempDir, context.GitHubOptions, subpath);
710
+ if (!download.Success) {
711
+ return { Success: false, ErrorMessage: `Failed to download teardown scripts: ${download.ErrorMessage}` };
712
+ }
713
+ const files = (download.Files ?? []).filter((f) => f.endsWith('.sql')).sort();
714
+ if (files.length === 0) {
715
+ context.Callbacks?.OnWarn?.('Metadata', `No teardown scripts in '${dir}' — this app's rows in the shared core schema will NOT be retired on remove.`);
716
+ return { Success: true };
717
+ }
718
+ const mjSchema = context.MJCoreSchema ?? '__mj';
719
+ context.Callbacks?.OnProgress?.('Metadata', `Running ${files.length} teardown script(s) against '${mjSchema}'...`);
720
+ await context.DatabaseProvider.BeginTransaction();
721
+ try {
722
+ for (const file of files) {
723
+ const sql = readFileSync(join(tempDir, file), 'utf-8').split('${mjSchema}').join(mjSchema);
724
+ if (sql.trim()) {
725
+ await context.DatabaseProvider.ExecuteSQL(sql);
726
+ }
727
+ }
728
+ await context.DatabaseProvider.CommitTransaction();
729
+ }
730
+ catch (error) {
731
+ await context.DatabaseProvider.RollbackTransaction();
732
+ const message = error instanceof Error ? error.message : String(error);
733
+ return { Success: false, ErrorMessage: `Teardown failed for '${manifest.name}' (rolled back): ${message}` };
734
+ }
735
+ context.Callbacks?.OnSuccess?.('Metadata', `Retired this app's rows from '${mjSchema}' (${files.length} teardown script(s)).`);
736
+ return { Success: true };
737
+ }
738
+ async function DownloadAppMigrations(manifest, context, tempDir, subpath) {
739
+ const baseDir = manifest.migrations.directory;
740
+ const isPG = context.DatabaseProvider.Dialect.PlatformKey === 'postgresql';
741
+ if (isPG) {
742
+ const pgDir = `${baseDir.replace(/\/+$/, '')}-pg`;
743
+ const pgResult = await DownloadMigrations(manifest.repository, manifest.version, pgDir, tempDir, context.GitHubOptions, subpath);
744
+ if (pgResult.Success && (pgResult.Files?.length ?? 0) > 0) {
745
+ return pgResult;
746
+ }
747
+ }
748
+ return DownloadMigrations(manifest.repository, manifest.version, baseDir, tempDir, context.GitHubOptions, subpath);
749
+ }
750
+ async function HandlePackageInstallation(manifest, context, packageVersion, versionStrategy, verbose) {
581
751
  if (!manifest.packages) {
582
752
  return { Success: true };
583
753
  }
@@ -606,7 +776,7 @@ async function HandlePackageInstallation(manifest, context, packageVersion, vers
606
776
  }
607
777
  }
608
778
  context.Callbacks?.OnProgress?.('Packages', 'Running package install...');
609
- const installResult = RunPackageInstall(context.RepoRoot, undefined, manifest.packages.registry, context.PackageManager);
779
+ const installResult = RunPackageInstall(context.RepoRoot, verbose, manifest.packages.registry, context.PackageManager);
610
780
  if (!installResult.Success) {
611
781
  return { Success: false, PackageJsonUpdated: true, ErrorMessage: installResult.ErrorMessage };
612
782
  }
@@ -618,6 +788,10 @@ function HandleServerConfig(manifest, context) {
618
788
  if (!dynamicResult.Success) {
619
789
  return { Success: false, ErrorMessage: dynamicResult.ErrorMessage };
620
790
  }
791
+ const clientResult = AddClientDynamicPackages(context.RepoRoot, manifest);
792
+ if (!clientResult.Success) {
793
+ return { Success: false, ErrorMessage: clientResult.ErrorMessage };
794
+ }
621
795
  const entityResult = AddEntityPackageMapping(context.RepoRoot, manifest);
622
796
  if (!entityResult.Success) {
623
797
  return { Success: false, ErrorMessage: entityResult.ErrorMessage };
@@ -646,104 +820,132 @@ function HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)
646
820
  manager.RemovePrebundleExcludes(manifest, otherManifests);
647
821
  manager.Save();
648
822
  }
649
- async function HandleClientBootstrapRegeneration(context) {
650
- context.Callbacks?.OnProgress?.('Config', 'Regenerating client bootstrap...');
651
- const apps = await ListInstalledApps(context.ContextUser);
652
- const appDeps = new Map();
653
- for (const app of apps) {
654
- const manifest = JSON.parse(app.ManifestJSON);
655
- const depNames = manifest.dependencies ? Object.keys(manifest.dependencies) : [];
656
- appDeps.set(app.Name, depNames);
657
- }
658
- const sortedNames = TopologicalSortApps(appDeps);
659
- const entries = [];
660
- const appsByName = new Map(apps.map((a) => [a.Name, a]));
661
- for (const name of sortedNames) {
662
- const app = appsByName.get(name);
663
- if (!app)
664
- continue;
665
- const manifest = JSON.parse(app.ManifestJSON);
666
- const clientPkgs = [...(manifest.packages?.client ?? []), ...(manifest.packages?.shared ?? [])];
667
- for (const pkg of clientPkgs) {
668
- entries.push({
669
- AppName: app.Name,
670
- Version: app.Version,
671
- PackageName: pkg.name,
672
- Enabled: app.Status === 'Active',
673
- });
674
- }
675
- }
676
- RegenerateClientBootstrap(context.RepoRoot, entries, context.ClientPackagePath, context.ClientBootstrapSubpath);
677
- }
678
- function TopologicalSortApps(appDeps) {
679
- const visited = new Set();
680
- const result = [];
681
- function Visit(name) {
682
- if (visited.has(name))
683
- return;
684
- visited.add(name);
685
- const deps = appDeps.get(name) ?? [];
686
- for (const dep of deps) {
687
- if (appDeps.has(dep)) {
688
- Visit(dep);
689
- }
690
- }
691
- result.push(name);
692
- }
693
- for (const name of appDeps.keys()) {
694
- Visit(name);
695
- }
696
- return result;
697
- }
698
823
  async function ExecuteHook(command, cwd) {
699
824
  const { execSync } = await import('node:child_process');
700
825
  execSync(command, { cwd, encoding: 'utf-8', timeout: 120000, stdio: 'inherit' });
701
826
  }
702
- async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
827
+ export async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks, provider) {
703
828
  try {
704
829
  const rv = new RunView();
705
830
  const escaped = EscapeSqlString(schemaName);
831
+ const md = (provider ?? new Metadata());
832
+ const tg = await md.CreateTransactionGroup();
833
+ const queueDeleteByFilterOrThrow = async (entityName, filter) => {
834
+ const r = await QueueDeleteEntitiesByFilter(rv, contextUser, entityName, filter, tg);
835
+ if (!r.Success) {
836
+ throw new Error(r.ErrorMessage ?? `Failed to queue delete of ${entityName} records`);
837
+ }
838
+ };
706
839
  const entityResult = await rv.RunView({
707
840
  EntityName: 'MJ: Entities',
708
841
  ExtraFilter: `SchemaName = '${escaped}'`,
709
842
  ResultType: 'entity_object',
710
843
  }, contextUser);
711
- if (!entityResult.Success || entityResult.Results.length === 0) {
712
- await DeleteEntitiesByFilter(rv, contextUser, 'MJ: Schema Info', `SchemaName = '${escaped}'`);
844
+ if (!entityResult.Success) {
845
+ throw new Error(`Failed to query entities for schema '${schemaName}': ${entityResult.ErrorMessage}`);
846
+ }
847
+ if (entityResult.Results.length === 0) {
848
+ await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
849
+ if (!(await tg.Submit())) {
850
+ throw new Error('Transaction failed: schema-info cleanup could not be committed');
851
+ }
713
852
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
714
- return;
853
+ return { Success: true };
715
854
  }
716
- const entityIds = entityResult.Results.map((e) => String(e.Get('ID')));
855
+ const entityIds = entityResult.Results.map((e) => e.ID);
717
856
  const idList = entityIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
718
- const entityIdFilter = `EntityID IN (${idList})`;
719
- await Promise.all([
720
- DeleteEntitiesByFilter(rv, contextUser, 'MJ: Entity Permissions', entityIdFilter),
721
- DeleteEntitiesByFilter(rv, contextUser, 'MJ: Application Entities', entityIdFilter),
722
- ]);
723
- await DeleteEntitiesByFilter(rv, contextUser, 'MJ: Entity Relationships', `EntityID IN (${idList}) OR RelatedEntityID IN (${idList})`);
724
- await DeleteEntitiesByFilter(rv, contextUser, 'MJ: Entity Fields', `EntityID IN (${idList})`);
857
+ const fieldResult = await rv.RunView({
858
+ EntityName: 'MJ: Entity Fields',
859
+ ExtraFilter: `EntityID IN (${idList})`,
860
+ ResultType: 'entity_object',
861
+ }, contextUser);
862
+ if (!fieldResult.Success) {
863
+ throw new Error(`Failed to query entity fields for schema '${schemaName}': ${fieldResult.ErrorMessage}`);
864
+ }
865
+ const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(f.ID)}'`).join(',');
866
+ const ownedAppIds = await FindAppOwnedApplications(rv, contextUser, entityIds, idList);
867
+ if (fieldIdList.length > 0) {
868
+ await queueDeleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
869
+ }
870
+ await queueDeleteByFilterOrThrow('MJ: Entity Permissions', `EntityID IN (${idList})`);
871
+ await queueDeleteByFilterOrThrow('MJ: Application Entities', `EntityID IN (${idList})`);
872
+ await queueDeleteByFilterOrThrow('MJ: Entity Settings', `EntityID IN (${idList})`);
873
+ await queueDeleteByFilterOrThrow('MJ: Entity Relationships', `EntityID IN (${idList}) OR RelatedEntityID IN (${idList})`);
874
+ await queueDeleteByFilterOrThrow('MJ: Entity Fields', `EntityID IN (${idList})`);
725
875
  for (const entity of entityResult.Results) {
876
+ entity.TransactionGroup = tg;
726
877
  await entity.Delete();
727
878
  }
728
- await DeleteEntitiesByFilter(rv, contextUser, 'MJ: Schema Info', `SchemaName = '${escaped}'`);
879
+ await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
880
+ if (!(await tg.Submit())) {
881
+ throw new Error('Transaction failed: entity metadata cleanup could not be committed atomically');
882
+ }
883
+ await DeleteAppOwnedApplications(rv, ownedAppIds, contextUser, callbacks);
729
884
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
885
+ return { Success: true };
730
886
  }
731
887
  catch (error) {
732
888
  const message = error instanceof Error ? error.message : String(error);
733
- callbacks?.OnWarn?.('Metadata', `Failed to remove entity metadata: ${message}`);
889
+ callbacks?.OnError?.('Metadata', `Failed to remove entity metadata: ${message}`);
890
+ return { Success: false, ErrorMessage: `Failed to remove entity metadata for schema '${schemaName}': ${message}` };
734
891
  }
735
892
  }
736
- async function DeleteEntitiesByFilter(rv, contextUser, entityName, filter) {
893
+ async function FindAppOwnedApplications(rv, contextUser, entityIds, idList) {
894
+ const ours = new Set(entityIds.map((id) => NormalizeUUID(id)));
895
+ const linked = await rv.RunView({ EntityName: 'MJ: Application Entities', ExtraFilter: `EntityID IN (${idList})`, Fields: ['ApplicationID'], ResultType: 'simple' }, contextUser);
896
+ if (!linked.Success) {
897
+ return [];
898
+ }
899
+ const candidateIds = [...new Set(linked.Results.map((r) => r.ApplicationID))];
900
+ if (candidateIds.length === 0) {
901
+ return [];
902
+ }
903
+ const candList = candidateIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
904
+ const allLinks = await rv.RunView({ EntityName: 'MJ: Application Entities', ExtraFilter: `ApplicationID IN (${candList})`, Fields: ['ApplicationID', 'EntityID'], ResultType: 'simple' }, contextUser);
905
+ if (!allLinks.Success) {
906
+ return [];
907
+ }
908
+ const allOursByApp = new Map();
909
+ for (const link of allLinks.Results) {
910
+ const wasAllOurs = allOursByApp.get(link.ApplicationID) ?? true;
911
+ allOursByApp.set(link.ApplicationID, wasAllOurs && ours.has(NormalizeUUID(link.EntityID)));
912
+ }
913
+ return [...allOursByApp.entries()].filter(([, allOurs]) => allOurs).map(([appId]) => appId);
914
+ }
915
+ async function DeleteAppOwnedApplications(rv, applicationIds, contextUser, callbacks) {
916
+ if (applicationIds.length === 0) {
917
+ return;
918
+ }
919
+ const idList = applicationIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
920
+ const appsResult = await rv.RunView({ EntityName: 'MJ: Applications', ExtraFilter: `ID IN (${idList})`, ResultType: 'entity_object' }, contextUser);
921
+ if (!appsResult.Success) {
922
+ return;
923
+ }
924
+ for (const app of appsResult.Results) {
925
+ try {
926
+ if (!(await app.Delete())) {
927
+ callbacks?.OnWarn?.('Metadata', `Left Application '${app.Name}' — it still has dependents; a reinstall will reuse it (${app.LatestResult?.CompleteMessage ?? 'has FK references'}).`);
928
+ }
929
+ }
930
+ catch (error) {
931
+ callbacks?.OnWarn?.('Metadata', `Could not remove app-owned Application '${app.Name}': ${error instanceof Error ? error.message : String(error)}`);
932
+ }
933
+ }
934
+ }
935
+ async function QueueDeleteEntitiesByFilter(rv, contextUser, entityName, filter, transactionGroup) {
737
936
  const result = await rv.RunView({
738
937
  EntityName: entityName,
739
938
  ExtraFilter: filter,
740
939
  ResultType: 'entity_object',
741
940
  }, contextUser);
742
- if (result.Success) {
743
- for (const record of result.Results) {
744
- await record.Delete();
745
- }
941
+ if (!result.Success) {
942
+ return { Success: false, ErrorMessage: `Failed to query ${entityName}: ${result.ErrorMessage}` };
943
+ }
944
+ for (const record of result.Results) {
945
+ record.TransactionGroup = transactionGroup;
946
+ await record.Delete();
746
947
  }
948
+ return { Success: true };
747
949
  }
748
950
  function GetDurationSeconds(startTime) {
749
951
  return Math.round((Date.now() - startTime) / 1000);