@memberjunction/open-app-engine 5.42.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 +319 -162
  27. package/dist/install/install-orchestrator.js.map +1 -1
  28. package/dist/install/migration-runner.d.ts +25 -0
  29. package/dist/install/migration-runner.d.ts.map +1 -1
  30. package/dist/install/migration-runner.js +5 -3
  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 +11 -14
  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,43 +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
- let metadataResult = { Success: true };
389
- if (existingApp.SchemaName) {
390
- Callbacks?.OnProgress?.('Metadata', `Removing entity metadata for schema '${existingApp.SchemaName}'...`);
391
- metadataResult = await RemoveAppEntityMetadata(existingApp.SchemaName, context.ContextUser, Callbacks);
392
- }
393
- let schemaDropError;
394
- if (!options.KeepData && existingApp.SchemaName) {
395
- Callbacks?.OnProgress?.('Schema', `Dropping schema '${existingApp.SchemaName}'...`);
396
- const dropResult = await DropAppSchema(existingApp.SchemaName, context.DatabaseProvider, {
397
- allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema === true,
398
- });
399
- if (!dropResult.Success) {
400
- schemaDropError = dropResult.ErrorMessage;
401
- Callbacks?.OnError?.('Schema', `Failed to drop schema: ${dropResult.ErrorMessage}`);
402
- }
403
- }
404
- const removalErrors = [metadataResult.Success ? undefined : metadataResult.ErrorMessage, schemaDropError]
405
- .filter((e) => !!e);
406
- if (removalErrors.length > 0) {
407
- const combined = removalErrors.join('; ');
474
+ try {
408
475
  await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
409
- Success: false,
476
+ Success: true,
410
477
  DurationSeconds: GetDurationSeconds(startTime),
411
478
  StartedAt: new Date(startTime),
412
479
  EndedAt: new Date(),
413
- Summary: `Remove failed: ${combined}`,
480
+ Summary: options.KeepData ? 'Removed (data kept)' : 'Removed (data dropped)',
414
481
  });
415
- await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
416
- return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, combined);
417
482
  }
418
- await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
419
- Success: true,
420
- DurationSeconds: GetDurationSeconds(startTime),
421
- StartedAt: new Date(startTime),
422
- EndedAt: new Date(),
423
- Summary: options.KeepData ? 'Removed (data kept)' : 'Removed (data dropped)',
424
- });
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
+ }
425
486
  await UpdateAppRecord(context.ContextUser, existingApp.ID, {
426
487
  Status: 'Removed',
427
488
  });
@@ -457,9 +518,11 @@ export async function DisableApp(appName, context) {
457
518
  if (!app) {
458
519
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
459
520
  }
460
- 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
+ }
461
525
  await SetAppStatus(context.ContextUser, app.ID, 'Disabled');
462
- await HandleClientBootstrapRegeneration(context);
463
526
  return {
464
527
  Success: true,
465
528
  Action: 'Install',
@@ -475,9 +538,11 @@ export async function EnableApp(appName, context) {
475
538
  if (!app) {
476
539
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
477
540
  }
478
- 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
+ }
479
545
  await SetAppStatus(context.ContextUser, app.ID, 'Active');
480
- await HandleClientBootstrapRegeneration(context);
481
546
  return {
482
547
  Success: true,
483
548
  Action: 'Install',
@@ -509,12 +574,12 @@ async function ResolveDependencyChain(manifest, context) {
509
574
  }
510
575
  const depsToInstall = (result.InstallOrder ?? [])
511
576
  .filter((d) => !d.AlreadyInstalled)
512
- .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 }));
513
578
  return { Success: true, DepsToInstall: depsToInstall };
514
579
  }
515
580
  function BuildManifestFetcher(context) {
516
- return async (repoUrl) => {
517
- const fetched = await FetchManifestFromGitHub(repoUrl, undefined, context.GitHubOptions);
581
+ return async (repoUrl, subpath) => {
582
+ const fetched = await FetchManifestFromGitHub(repoUrl, undefined, context.GitHubOptions, subpath);
518
583
  if (!fetched.Success || !fetched.ManifestJSON) {
519
584
  return { Success: false, ErrorMessage: fetched.ErrorMessage ?? 'Failed to fetch manifest' };
520
585
  }
@@ -541,8 +606,14 @@ async function InstallDependencies(deps, context, inherited) {
541
606
  };
542
607
  }
543
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
+ }
544
613
  const result = await InstallApp({
545
614
  Source: dep.Repository,
615
+ Subpath: dep.Subpath,
616
+ Version: resolved.Version,
546
617
  _skipDependencyResolution: true,
547
618
  AllowDoubleUnderscoreSchema: inherited.AllowDoubleUnderscoreSchema,
548
619
  Verbose: inherited.Verbose,
@@ -553,6 +624,35 @@ async function InstallDependencies(deps, context, inherited) {
553
624
  }
554
625
  return { Success: true };
555
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
+ }
556
656
  async function HandleSchemaCreation(manifest, context, isReinstall = false, allowDoubleUnderscore = false) {
557
657
  if (!manifest.schema) {
558
658
  return { Success: true };
@@ -562,25 +662,26 @@ async function HandleSchemaCreation(manifest, context, isReinstall = false, allo
562
662
  if (exists) {
563
663
  if (isReinstall || manifest.schema.createIfNotExists !== false) {
564
664
  context.Callbacks?.OnProgress?.('Schema', `Reusing existing schema '${manifest.schema.name}'`);
565
- return { Success: true };
665
+ return { Success: true, Created: false };
566
666
  }
567
667
  return { Success: false, ErrorMessage: `Schema '${manifest.schema.name}' already exists` };
568
668
  }
569
669
  if (manifest.schema.createIfNotExists !== false) {
570
670
  context.Callbacks?.OnProgress?.('Schema', `Creating schema '${manifest.schema.name}'...`);
571
671
  const result = await CreateAppSchema(manifest.schema.name, context.DatabaseProvider, { allowDoubleUnderscore });
572
- return { Success: result.Success, ErrorMessage: result.ErrorMessage };
672
+ return { Success: result.Success, ErrorMessage: result.ErrorMessage, Created: result.Success };
573
673
  }
574
674
  return { Success: false, ErrorMessage: `Schema '${manifest.schema.name}' does not exist and createIfNotExists is false` };
575
675
  }
576
- async function HandleMigrations(manifest, context) {
676
+ async function HandleMigrations(manifest, context, subpath) {
577
677
  if (!manifest.schema || !manifest.migrations) {
578
678
  return { Success: true };
579
679
  }
580
680
  context.Callbacks?.OnProgress?.('Migration', 'Downloading migration files...');
581
681
  const tempDir = join(tmpdir(), `mj-app-${manifest.name}-${Date.now()}`);
582
682
  mkdirSync(tempDir, { recursive: true });
583
- 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);
584
685
  if (!downloadResult.Success) {
585
686
  return { Success: false, ErrorMessage: downloadResult.ErrorMessage };
586
687
  }
@@ -591,11 +692,62 @@ async function HandleMigrations(manifest, context) {
591
692
  DatabaseConfig: context.DatabaseConfig,
592
693
  MJCoreSchema: context.MJCoreSchema,
593
694
  ExtraPlaceholders: context.MigrationPlaceholders,
594
- Platform: context.DatabaseProvider.Dialect.PlatformKey,
695
+ Platform: platform,
595
696
  });
596
697
  return { Success: migrationResult.Success, ErrorMessage: migrationResult.ErrorMessage };
597
698
  }
598
- 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) {
599
751
  if (!manifest.packages) {
600
752
  return { Success: true };
601
753
  }
@@ -624,7 +776,7 @@ async function HandlePackageInstallation(manifest, context, packageVersion, vers
624
776
  }
625
777
  }
626
778
  context.Callbacks?.OnProgress?.('Packages', 'Running package install...');
627
- const installResult = RunPackageInstall(context.RepoRoot, undefined, manifest.packages.registry, context.PackageManager);
779
+ const installResult = RunPackageInstall(context.RepoRoot, verbose, manifest.packages.registry, context.PackageManager);
628
780
  if (!installResult.Success) {
629
781
  return { Success: false, PackageJsonUpdated: true, ErrorMessage: installResult.ErrorMessage };
630
782
  }
@@ -636,6 +788,10 @@ function HandleServerConfig(manifest, context) {
636
788
  if (!dynamicResult.Success) {
637
789
  return { Success: false, ErrorMessage: dynamicResult.ErrorMessage };
638
790
  }
791
+ const clientResult = AddClientDynamicPackages(context.RepoRoot, manifest);
792
+ if (!clientResult.Success) {
793
+ return { Success: false, ErrorMessage: clientResult.ErrorMessage };
794
+ }
639
795
  const entityResult = AddEntityPackageMapping(context.RepoRoot, manifest);
640
796
  if (!entityResult.Success) {
641
797
  return { Success: false, ErrorMessage: entityResult.ErrorMessage };
@@ -664,67 +820,20 @@ function HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)
664
820
  manager.RemovePrebundleExcludes(manifest, otherManifests);
665
821
  manager.Save();
666
822
  }
667
- async function HandleClientBootstrapRegeneration(context) {
668
- context.Callbacks?.OnProgress?.('Config', 'Regenerating client bootstrap...');
669
- const apps = await ListInstalledApps(context.ContextUser);
670
- const appDeps = new Map();
671
- for (const app of apps) {
672
- const manifest = JSON.parse(app.ManifestJSON);
673
- const depNames = manifest.dependencies ? Object.keys(manifest.dependencies) : [];
674
- appDeps.set(app.Name, depNames);
675
- }
676
- const sortedNames = TopologicalSortApps(appDeps);
677
- const entries = [];
678
- const appsByName = new Map(apps.map((a) => [a.Name, a]));
679
- for (const name of sortedNames) {
680
- const app = appsByName.get(name);
681
- if (!app)
682
- continue;
683
- const manifest = JSON.parse(app.ManifestJSON);
684
- const clientPkgs = [...(manifest.packages?.client ?? []), ...(manifest.packages?.shared ?? [])];
685
- for (const pkg of clientPkgs) {
686
- entries.push({
687
- AppName: app.Name,
688
- Version: app.Version,
689
- PackageName: pkg.name,
690
- Enabled: app.Status === 'Active',
691
- });
692
- }
693
- }
694
- RegenerateClientBootstrap(context.RepoRoot, entries, context.ClientPackagePath, context.ClientBootstrapSubpath);
695
- }
696
- function TopologicalSortApps(appDeps) {
697
- const visited = new Set();
698
- const result = [];
699
- function Visit(name) {
700
- if (visited.has(name))
701
- return;
702
- visited.add(name);
703
- const deps = appDeps.get(name) ?? [];
704
- for (const dep of deps) {
705
- if (appDeps.has(dep)) {
706
- Visit(dep);
707
- }
708
- }
709
- result.push(name);
710
- }
711
- for (const name of appDeps.keys()) {
712
- Visit(name);
713
- }
714
- return result;
715
- }
716
823
  async function ExecuteHook(command, cwd) {
717
824
  const { execSync } = await import('node:child_process');
718
825
  execSync(command, { cwd, encoding: 'utf-8', timeout: 120000, stdio: 'inherit' });
719
826
  }
720
- async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
827
+ export async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks, provider) {
721
828
  try {
722
829
  const rv = new RunView();
723
830
  const escaped = EscapeSqlString(schemaName);
724
- const deleteByFilterOrThrow = async (entityName, filter) => {
725
- const r = await DeleteEntitiesByFilter(rv, contextUser, entityName, filter);
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);
726
835
  if (!r.Success) {
727
- throw new Error(r.ErrorMessage ?? `Failed to delete ${entityName} records`);
836
+ throw new Error(r.ErrorMessage ?? `Failed to queue delete of ${entityName} records`);
728
837
  }
729
838
  };
730
839
  const entityResult = await rv.RunView({
@@ -736,11 +845,14 @@ async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
736
845
  throw new Error(`Failed to query entities for schema '${schemaName}': ${entityResult.ErrorMessage}`);
737
846
  }
738
847
  if (entityResult.Results.length === 0) {
739
- await deleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
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
+ }
740
852
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
741
853
  return { Success: true };
742
854
  }
743
- const entityIds = entityResult.Results.map((e) => String(e.Get('ID')));
855
+ const entityIds = entityResult.Results.map((e) => e.ID);
744
856
  const idList = entityIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
745
857
  const fieldResult = await rv.RunView({
746
858
  EntityName: 'MJ: Entity Fields',
@@ -750,21 +862,25 @@ async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
750
862
  if (!fieldResult.Success) {
751
863
  throw new Error(`Failed to query entity fields for schema '${schemaName}': ${fieldResult.ErrorMessage}`);
752
864
  }
753
- const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(String(f.Get('ID')))}'`).join(',');
865
+ const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(f.ID)}'`).join(',');
866
+ const ownedAppIds = await FindAppOwnedApplications(rv, contextUser, entityIds, idList);
754
867
  if (fieldIdList.length > 0) {
755
- await deleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
868
+ await queueDeleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
756
869
  }
757
- await deleteByFilterOrThrow('MJ: Entity Permissions', `EntityID IN (${idList})`);
758
- await deleteByFilterOrThrow('MJ: Application Entities', `EntityID IN (${idList})`);
759
- await deleteByFilterOrThrow('MJ: Entity Settings', `EntityID IN (${idList})`);
760
- await deleteByFilterOrThrow('MJ: Entity Relationships', `EntityID IN (${idList}) OR RelatedEntityID IN (${idList})`);
761
- await deleteByFilterOrThrow('MJ: Entity Fields', `EntityID IN (${idList})`);
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})`);
762
875
  for (const entity of entityResult.Results) {
763
- if (!(await entity.Delete())) {
764
- throw new Error(`Failed to delete entity '${String(entity.Get('Name'))}': ${entity.LatestResult?.CompleteMessage ?? 'unknown error'}`);
765
- }
876
+ entity.TransactionGroup = tg;
877
+ await entity.Delete();
766
878
  }
767
- await deleteByFilterOrThrow('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);
768
884
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
769
885
  return { Success: true };
770
886
  }
@@ -774,7 +890,49 @@ async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
774
890
  return { Success: false, ErrorMessage: `Failed to remove entity metadata for schema '${schemaName}': ${message}` };
775
891
  }
776
892
  }
777
- 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) {
778
936
  const result = await rv.RunView({
779
937
  EntityName: entityName,
780
938
  ExtraFilter: filter,
@@ -784,9 +942,8 @@ async function DeleteEntitiesByFilter(rv, contextUser, entityName, filter) {
784
942
  return { Success: false, ErrorMessage: `Failed to query ${entityName}: ${result.ErrorMessage}` };
785
943
  }
786
944
  for (const record of result.Results) {
787
- if (!(await record.Delete())) {
788
- return { Success: false, ErrorMessage: `Failed to delete a ${entityName} record: ${record.LatestResult?.CompleteMessage ?? 'unknown error'}` };
789
- }
945
+ record.TransactionGroup = transactionGroup;
946
+ await record.Delete();
790
947
  }
791
948
  return { Success: true };
792
949
  }