@memberjunction/open-app-engine 5.42.0 → 5.44.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 +258 -33
  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 +341 -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,25 @@ 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;
78
+ await PersistCanonicalSchemaName(manifest, context);
76
79
  }
77
80
  if (manifest.migrations && manifest.schema) {
78
- const migrationResult = await HandleMigrations(manifest, context);
81
+ const migrationResult = await HandleMigrations(manifest, context, subpath);
79
82
  if (!migrationResult.Success) {
80
83
  await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
81
84
  return BuildFailureResult('Install', manifest.name, manifest.version, 'Migration', startTime, migrationResult.ErrorMessage ?? 'Migration failed');
82
85
  }
83
86
  }
84
87
  Callbacks?.OnProgress?.('Record', 'Recording app installation...');
85
- const recordResult = await RecordInstallationAtomically(context.ContextUser, manifest, Callbacks);
88
+ const recordResult = await RecordInstallationAtomically(context.ContextUser, manifest, Callbacks, undefined, subpath);
86
89
  if (!recordResult.Success) {
87
90
  await CompensateSchemaOnFailure(manifest, context, schemaCreated, options.AllowDoubleUnderscoreSchema === true, Callbacks);
88
91
  return BuildFailureResult('Install', manifest.name, manifest.version, 'Record', startTime, recordResult.ErrorMessage ?? 'Failed to record installation');
89
92
  }
90
93
  createdAppId = recordResult.AppId;
91
94
  Callbacks?.OnProgress?.('Config', 'Updating configuration files...');
92
- const pkgResult = await HandlePackageInstallation(manifest, context, effectivePackageVersion, effectiveVersionStrategy);
95
+ const pkgResult = await HandlePackageInstallation(manifest, context, effectivePackageVersion, effectiveVersionStrategy, options.Verbose);
93
96
  let npmInstallWarning;
94
97
  if (!pkgResult.Success) {
95
98
  if (pkgResult.PackageJsonUpdated) {
@@ -110,24 +113,38 @@ export async function InstallApp(options, context) {
110
113
  }
111
114
  HandleAngularPrebundleExcludes(manifest, context);
112
115
  Callbacks?.OnProgress?.('Record', 'Finalizing installation...');
113
- await SetAppStatus(context.ContextUser, createdAppId, 'Active');
114
- await HandleClientBootstrapRegeneration(context);
116
+ const finalStatus = npmInstallWarning ? 'Disabled' : 'Active';
117
+ await SetAppStatus(context.ContextUser, createdAppId, finalStatus);
118
+ if (finalStatus !== 'Active') {
119
+ ToggleServerDynamicPackages(context.RepoRoot, manifest.name, false);
120
+ }
115
121
  if (manifest.hooks?.postInstall) {
116
122
  Callbacks?.OnProgress?.('Hooks', 'Running postInstall hook...');
117
123
  await ExecuteHook(manifest.hooks.postInstall, context.RepoRoot);
118
124
  }
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
- });
125
+ try {
126
+ await RecordInstallHistoryEntry(context.ContextUser, createdAppId, 'Install', manifest, {
127
+ Success: true,
128
+ DurationSeconds: GetDurationSeconds(startTime),
129
+ StartedAt: new Date(startTime),
130
+ EndedAt: new Date(),
131
+ Summary: 'Initial installation',
132
+ });
133
+ }
134
+ catch (histErr) {
135
+ Callbacks?.OnWarn?.('Record', `App installed, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
136
+ }
126
137
  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;
138
+ let summary;
139
+ if (npmInstallWarning) {
140
+ 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}'.`;
141
+ }
142
+ else if (manifest.schema) {
143
+ 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.`;
144
+ }
145
+ else {
146
+ summary = 'App installed successfully. Restart MJAPI and rebuild MJExplorer to activate.';
147
+ }
131
148
  return {
132
149
  Success: true,
133
150
  Action: 'Install',
@@ -151,11 +168,11 @@ export async function InstallApp(options, context) {
151
168
  return BuildFailureResult('Install', options.Source, '', 'Schema', startTime, message);
152
169
  }
153
170
  }
154
- async function RecordInstallationAtomically(contextUser, manifest, callbacks, provider) {
171
+ async function RecordInstallationAtomically(contextUser, manifest, callbacks, provider, subpath) {
155
172
  const md = (provider ?? new Metadata());
156
173
  const tg = await md.CreateTransactionGroup();
157
174
  try {
158
- const appId = await RecordAppInstallation(contextUser, manifest, callbacks, tg, 'Installing');
175
+ const appId = await RecordAppInstallation(contextUser, manifest, callbacks, tg, 'Installing', provider, subpath);
159
176
  if (manifest.dependencies) {
160
177
  await RecordAppDependencies(contextUser, appId, manifest.dependencies, tg);
161
178
  }
@@ -199,12 +216,12 @@ export async function UpgradeApp(options, context) {
199
216
  upgradeAppId = existingApp.ID;
200
217
  previousVersion = existingApp.Version;
201
218
  const explicitUpgradeVersion = options.Version;
202
- const targetVersion = explicitUpgradeVersion ?? (await GetLatestVersion(existingApp.RepositoryURL, context.GitHubOptions));
219
+ const targetVersion = explicitUpgradeVersion ?? (await GetLatestVersion(existingApp.RepositoryURL, context.GitHubOptions, existingApp.Subpath ?? undefined));
203
220
  if (!targetVersion) {
204
221
  return BuildFailureResult('Upgrade', options.AppName, '', 'Schema', startTime, 'Could not determine target version');
205
222
  }
206
223
  if (explicitUpgradeVersion) {
207
- const tagResult = await ValidateGitHubTag(existingApp.RepositoryURL, targetVersion, context.GitHubOptions);
224
+ const tagResult = await ValidateGitHubTag(existingApp.RepositoryURL, targetVersion, context.GitHubOptions, existingApp.Subpath ?? undefined);
208
225
  if (!tagResult.Exists) {
209
226
  return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Schema', startTime, tagResult.ErrorMessage ?? `Version ${targetVersion} not found`);
210
227
  }
@@ -223,8 +240,9 @@ export async function UpgradeApp(options, context) {
223
240
  Summary: upgradeCheck.Message ?? `Already at version ${targetVersion}`,
224
241
  };
225
242
  }
243
+ const subpath = existingApp.Subpath ?? undefined;
226
244
  Callbacks?.OnProgress?.('Fetch', `Fetching manifest for ${options.AppName} v${targetVersion}...`);
227
- const fetchResult = await FetchManifestFromGitHub(existingApp.RepositoryURL, targetVersion, context.GitHubOptions);
245
+ const fetchResult = await FetchManifestFromGitHub(existingApp.RepositoryURL, targetVersion, context.GitHubOptions, subpath);
228
246
  if (!fetchResult.Success || !fetchResult.ManifestJSON) {
229
247
  return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Schema', startTime, fetchResult.ErrorMessage ?? 'Failed to fetch manifest');
230
248
  }
@@ -254,16 +272,21 @@ export async function UpgradeApp(options, context) {
254
272
  }
255
273
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Upgrading');
256
274
  if (manifest.migrations && manifest.schema) {
257
- const migrationResult = await HandleMigrations(manifest, context);
275
+ const migrationResult = await HandleMigrations(manifest, context, subpath);
258
276
  if (!migrationResult.Success) {
259
- await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Migration', migrationResult.ErrorMessage ?? 'Migration failed', startTime, previousVersion);
277
+ const detail = migrationResult.ErrorMessage ?? 'Migration failed';
278
+ const recoverable = `${detail}\n\nThe schema may be partially upgraded (migrations are forward-only; there is no ` +
279
+ `automatic rollback). Skyway tracks applied migrations, so once the cause is fixed, re-run ` +
280
+ `'mj app upgrade ${options.AppName}' to resume from the last successful migration. If the schema ` +
281
+ `is left inconsistent, restore it from a backup taken before the upgrade.`;
282
+ await RecordFailureHistory(context.ContextUser, existingApp.ID, 'Upgrade', manifest, 'Migration', recoverable, startTime, previousVersion);
260
283
  await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
261
- return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Migration', startTime, migrationResult.ErrorMessage ?? 'Migration failed');
284
+ return BuildFailureResult('Upgrade', options.AppName, targetVersion, 'Migration', startTime, recoverable);
262
285
  }
263
286
  }
264
287
  const effectiveUpgradeVersion = explicitUpgradeVersion ? targetVersion.replace(/^v/, '') : manifest.version;
265
288
  const effectiveUpgradeStrategy = explicitUpgradeVersion ? 'exact' : context.VersionStrategy;
266
- const pkgResult = await HandlePackageInstallation(manifest, context, effectiveUpgradeVersion, effectiveUpgradeStrategy);
289
+ const pkgResult = await HandlePackageInstallation(manifest, context, effectiveUpgradeVersion, effectiveUpgradeStrategy, options.Verbose);
267
290
  let npmInstallWarning;
268
291
  if (!pkgResult.Success) {
269
292
  if (pkgResult.PackageJsonUpdated) {
@@ -288,23 +311,29 @@ export async function UpgradeApp(options, context) {
288
311
  ManifestJSON: JSON.stringify(manifest),
289
312
  Status: 'Active',
290
313
  });
291
- await HandleClientBootstrapRegeneration(context);
292
314
  if (manifest.hooks?.postUpgrade) {
293
315
  Callbacks?.OnProgress?.('Hooks', 'Running postUpgrade hook...');
294
316
  await ExecuteHook(manifest.hooks.postUpgrade, context.RepoRoot);
295
317
  }
296
318
  if (manifest.dependencies) {
297
- await DeleteAppDependencies(context.ContextUser, existingApp.ID);
298
- await RecordAppDependencies(context.ContextUser, existingApp.ID, manifest.dependencies);
319
+ const depsReplaced = await ReplaceAppDependenciesAtomically(context.ContextUser, existingApp.ID, manifest.dependencies);
320
+ if (!depsReplaced) {
321
+ Callbacks?.OnWarn?.('Record', 'App upgraded, but its dependency records could not be updated atomically — re-run the upgrade to refresh them.');
322
+ }
323
+ }
324
+ try {
325
+ await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Upgrade', manifest, {
326
+ PreviousVersion: previousVersion,
327
+ Success: true,
328
+ DurationSeconds: GetDurationSeconds(startTime),
329
+ StartedAt: new Date(startTime),
330
+ EndedAt: new Date(),
331
+ Summary: `Upgraded from ${previousVersion} to ${manifest.version}`,
332
+ });
333
+ }
334
+ catch (histErr) {
335
+ Callbacks?.OnWarn?.('Record', `App upgraded, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
299
336
  }
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
337
  Callbacks?.OnSuccess?.('Upgrade', `Successfully upgraded ${options.AppName} to v${manifest.version}`);
309
338
  const baseSummary = `Upgraded from ${previousVersion} to ${manifest.version}. Restart MJAPI and rebuild MJExplorer.`;
310
339
  const summary = npmInstallWarning
@@ -359,15 +388,73 @@ export async function RemoveApp(options, context) {
359
388
  Callbacks?.OnProgress?.('Hooks', 'Running preRemove hook...');
360
389
  await ExecuteHook(manifest.hooks.preRemove, context.RepoRoot);
361
390
  }
391
+ const shareCheck = existingApp.SchemaName
392
+ ? await CheckSchemaSharedByOtherApps(context.ContextUser, existingApp.SchemaName, existingApp.ID)
393
+ : { Shared: false, CheckFailed: false };
394
+ const schemaShared = shareCheck.Shared;
395
+ const shareCheckError = shareCheck.CheckFailed
396
+ ? `Could not determine whether schema '${existingApp.SchemaName}' is shared by other Open Apps (share-check query failed): ${shareCheck.ErrorMessage ?? 'unknown error'}`
397
+ : undefined;
398
+ if (schemaShared && !shareCheck.CheckFailed) {
399
+ Callbacks?.OnWarn?.('Schema', `Schema '${existingApp.SchemaName}' is still used by another installed Open App — skipping metadata + schema removal to protect co-tenant data.`);
400
+ }
401
+ let metadataResult = { Success: true };
402
+ if (existingApp.SchemaName && !schemaShared) {
403
+ Callbacks?.OnProgress?.('Metadata', `Removing entity metadata for schema '${existingApp.SchemaName}'...`);
404
+ metadataResult = await RemoveAppEntityMetadata(existingApp.SchemaName, context.ContextUser, Callbacks);
405
+ }
406
+ let teardownResult = { Success: true };
407
+ if (!options.KeepData) {
408
+ teardownResult = await HandleTeardown(manifest, context, existingApp.Subpath ?? undefined);
409
+ if (!teardownResult.Success) {
410
+ Callbacks?.OnError?.('Metadata', `Teardown failed: ${teardownResult.ErrorMessage}`);
411
+ }
412
+ }
413
+ let schemaDropError;
414
+ if (!options.KeepData && existingApp.SchemaName && !schemaShared) {
415
+ Callbacks?.OnProgress?.('Schema', `Dropping schema '${existingApp.SchemaName}'...`);
416
+ const dropResult = await DropAppSchema(existingApp.SchemaName, context.DatabaseProvider, {
417
+ allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema === true,
418
+ });
419
+ if (!dropResult.Success) {
420
+ schemaDropError = dropResult.ErrorMessage;
421
+ Callbacks?.OnError?.('Schema', `Failed to drop schema: ${dropResult.ErrorMessage}`);
422
+ }
423
+ }
424
+ const removalErrors = [
425
+ shareCheckError,
426
+ teardownResult.Success ? undefined : teardownResult.ErrorMessage,
427
+ metadataResult.Success ? undefined : metadataResult.ErrorMessage,
428
+ schemaDropError,
429
+ ].filter((e) => !!e);
430
+ if (removalErrors.length > 0) {
431
+ const combined = removalErrors.join('; ');
432
+ await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
433
+ Success: false,
434
+ DurationSeconds: GetDurationSeconds(startTime),
435
+ StartedAt: new Date(startTime),
436
+ EndedAt: new Date(),
437
+ Summary: `Remove failed: ${combined}`,
438
+ });
439
+ await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
440
+ return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, combined);
441
+ }
362
442
  Callbacks?.OnProgress?.('Config', 'Removing config, client bootstrap, and package references...');
363
443
  const otherApps = (await ListInstalledApps(context.ContextUser))
364
444
  .filter(a => a.Name !== options.AppName && a.Status !== 'Removed');
365
- const otherManifests = otherApps.map(a => JSON.parse(a.ManifestJSON));
445
+ const otherManifests = otherApps.flatMap(a => {
446
+ try {
447
+ return [JSON.parse(a.ManifestJSON)];
448
+ }
449
+ catch {
450
+ Callbacks?.OnWarn?.('Config', `Ignoring app '${a.Name}' when computing shared excludes — its ManifestJSON could not be parsed.`);
451
+ return [];
452
+ }
453
+ });
366
454
  await Promise.all([
367
455
  Promise.resolve(RemoveServerDynamicPackages(context.RepoRoot, options.AppName)),
368
456
  Promise.resolve(manifest.schema ? RemoveEntityPackageMapping(context.RepoRoot, manifest.schema.name) : undefined),
369
457
  Promise.resolve(HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)),
370
- HandleClientBootstrapRegeneration(context),
371
458
  Promise.resolve(RemoveAppPackages({
372
459
  RepoRoot: context.RepoRoot,
373
460
  ServerPackages: manifest.packages?.server ?? [],
@@ -385,43 +472,18 @@ export async function RemoveApp(options, context) {
385
472
  if (!installResult.Success) {
386
473
  Callbacks?.OnWarn?.('Packages', `Package install warning during removal: ${installResult.ErrorMessage}`);
387
474
  }
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('; ');
475
+ try {
408
476
  await RecordInstallHistoryEntry(context.ContextUser, existingApp.ID, 'Remove', manifest, {
409
- Success: false,
477
+ Success: true,
410
478
  DurationSeconds: GetDurationSeconds(startTime),
411
479
  StartedAt: new Date(startTime),
412
480
  EndedAt: new Date(),
413
- Summary: `Remove failed: ${combined}`,
481
+ Summary: options.KeepData ? 'Removed (data kept)' : 'Removed (data dropped)',
414
482
  });
415
- await SetAppStatus(context.ContextUser, existingApp.ID, 'Error');
416
- return BuildFailureResult('Remove', options.AppName, existingApp.Version, 'Schema', startTime, combined);
417
483
  }
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
- });
484
+ catch (histErr) {
485
+ Callbacks?.OnWarn?.('Record', `App removed, but the history audit entry could not be written: ${histErr instanceof Error ? histErr.message : String(histErr)}`);
486
+ }
425
487
  await UpdateAppRecord(context.ContextUser, existingApp.ID, {
426
488
  Status: 'Removed',
427
489
  });
@@ -457,9 +519,11 @@ export async function DisableApp(appName, context) {
457
519
  if (!app) {
458
520
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
459
521
  }
460
- ToggleServerDynamicPackages(context.RepoRoot, appName, false);
522
+ const toggle = ToggleServerDynamicPackages(context.RepoRoot, appName, false);
523
+ if (!toggle.Success) {
524
+ return BuildFailureResult('Install', appName, app.Version, 'Config', startTime, toggle.ErrorMessage ?? 'Failed to update dynamicPackages.server in mj.config.cjs');
525
+ }
461
526
  await SetAppStatus(context.ContextUser, app.ID, 'Disabled');
462
- await HandleClientBootstrapRegeneration(context);
463
527
  return {
464
528
  Success: true,
465
529
  Action: 'Install',
@@ -475,9 +539,11 @@ export async function EnableApp(appName, context) {
475
539
  if (!app) {
476
540
  return BuildFailureResult('Install', appName, '', 'Config', startTime, `App '${appName}' is not installed`);
477
541
  }
478
- ToggleServerDynamicPackages(context.RepoRoot, appName, true);
542
+ const toggle = ToggleServerDynamicPackages(context.RepoRoot, appName, true);
543
+ if (!toggle.Success) {
544
+ return BuildFailureResult('Install', appName, app.Version, 'Config', startTime, toggle.ErrorMessage ?? 'Failed to update dynamicPackages.server in mj.config.cjs');
545
+ }
479
546
  await SetAppStatus(context.ContextUser, app.ID, 'Active');
480
- await HandleClientBootstrapRegeneration(context);
481
547
  return {
482
548
  Success: true,
483
549
  Action: 'Install',
@@ -509,12 +575,12 @@ async function ResolveDependencyChain(manifest, context) {
509
575
  }
510
576
  const depsToInstall = (result.InstallOrder ?? [])
511
577
  .filter((d) => !d.AlreadyInstalled)
512
- .map((d) => ({ AppName: d.AppName, Repository: d.Repository, VersionRange: d.VersionRange }));
578
+ .map((d) => ({ AppName: d.AppName, Repository: d.Repository, VersionRange: d.VersionRange, Subpath: d.Subpath }));
513
579
  return { Success: true, DepsToInstall: depsToInstall };
514
580
  }
515
581
  function BuildManifestFetcher(context) {
516
- return async (repoUrl) => {
517
- const fetched = await FetchManifestFromGitHub(repoUrl, undefined, context.GitHubOptions);
582
+ return async (repoUrl, subpath) => {
583
+ const fetched = await FetchManifestFromGitHub(repoUrl, undefined, context.GitHubOptions, subpath);
518
584
  if (!fetched.Success || !fetched.ManifestJSON) {
519
585
  return { Success: false, ErrorMessage: fetched.ErrorMessage ?? 'Failed to fetch manifest' };
520
586
  }
@@ -541,8 +607,14 @@ async function InstallDependencies(deps, context, inherited) {
541
607
  };
542
608
  }
543
609
  context.Callbacks?.OnProgress?.('Dependencies', `Installing dependency from ${dep.Repository}...`);
610
+ const resolved = await ResolveDependencyVersion(dep.Repository, dep.VersionRange, context.GitHubOptions);
611
+ if (resolved.ErrorMessage) {
612
+ return { Success: false, ErrorMessage: `Cannot install dependency '${dep.AppName}': ${resolved.ErrorMessage}` };
613
+ }
544
614
  const result = await InstallApp({
545
615
  Source: dep.Repository,
616
+ Subpath: dep.Subpath,
617
+ Version: resolved.Version,
546
618
  _skipDependencyResolution: true,
547
619
  AllowDoubleUnderscoreSchema: inherited.AllowDoubleUnderscoreSchema,
548
620
  Verbose: inherited.Verbose,
@@ -553,6 +625,35 @@ async function InstallDependencies(deps, context, inherited) {
553
625
  }
554
626
  return { Success: true };
555
627
  }
628
+ export async function ResolveDependencyVersion(repoUrl, versionRange, options) {
629
+ const range = (versionRange ?? '').trim();
630
+ if (range === '' || range === '*' || range.toLowerCase() === 'latest') {
631
+ return { Version: undefined };
632
+ }
633
+ if (semver.valid(range)) {
634
+ return { Version: range.replace(/^v/, '') };
635
+ }
636
+ if (!semver.validRange(range)) {
637
+ return { ErrorMessage: `version '${versionRange}' is not a valid semver version or range` };
638
+ }
639
+ const [releases, tags] = await Promise.all([
640
+ ListGitHubReleases(repoUrl, options),
641
+ ListGitHubTags(repoUrl, options),
642
+ ]);
643
+ const candidates = [
644
+ ...releases.filter((r) => !r.Draft).map((r) => r.TagName.replace(/^v/, '')),
645
+ ...tags.map((t) => t.replace(/^v/, '')),
646
+ ].filter((v) => semver.valid(v) != null);
647
+ if (candidates.length === 0) {
648
+ return { ErrorMessage: `no published versions found at ${repoUrl} to satisfy '${versionRange}'` };
649
+ }
650
+ const best = semver.maxSatisfying(candidates, range);
651
+ if (!best) {
652
+ const latest = candidates.sort(semver.rcompare)[0];
653
+ return { ErrorMessage: `no published version at ${repoUrl} satisfies '${versionRange}' (latest available: ${latest})` };
654
+ }
655
+ return { Version: best };
656
+ }
556
657
  async function HandleSchemaCreation(manifest, context, isReinstall = false, allowDoubleUnderscore = false) {
557
658
  if (!manifest.schema) {
558
659
  return { Success: true };
@@ -562,25 +663,47 @@ async function HandleSchemaCreation(manifest, context, isReinstall = false, allo
562
663
  if (exists) {
563
664
  if (isReinstall || manifest.schema.createIfNotExists !== false) {
564
665
  context.Callbacks?.OnProgress?.('Schema', `Reusing existing schema '${manifest.schema.name}'`);
565
- return { Success: true };
666
+ return { Success: true, Created: false };
566
667
  }
567
668
  return { Success: false, ErrorMessage: `Schema '${manifest.schema.name}' already exists` };
568
669
  }
569
670
  if (manifest.schema.createIfNotExists !== false) {
570
671
  context.Callbacks?.OnProgress?.('Schema', `Creating schema '${manifest.schema.name}'...`);
571
672
  const result = await CreateAppSchema(manifest.schema.name, context.DatabaseProvider, { allowDoubleUnderscore });
572
- return { Success: result.Success, ErrorMessage: result.ErrorMessage };
673
+ return { Success: result.Success, ErrorMessage: result.ErrorMessage, Created: result.Success };
573
674
  }
574
675
  return { Success: false, ErrorMessage: `Schema '${manifest.schema.name}' does not exist and createIfNotExists is false` };
575
676
  }
576
- async function HandleMigrations(manifest, context) {
677
+ async function PersistCanonicalSchemaName(manifest, context) {
678
+ if (!manifest.schema) {
679
+ return;
680
+ }
681
+ const canonicalName = manifest.schema.name;
682
+ try {
683
+ const dialect = context.DatabaseProvider.Dialect;
684
+ const mjSchema = context.MJCoreSchema ?? '__mj';
685
+ const physicalName = dialect.CanonicalSchemaName(canonicalName);
686
+ const table = dialect.QuoteSchema(mjSchema, 'SchemaInfo');
687
+ const canonicalLiteral = dialect.QuoteStringLiteral(canonicalName);
688
+ const physicalLiteral = dialect.QuoteStringLiteral(physicalName);
689
+ const sql = `UPDATE ${table} SET ${dialect.QuoteIdentifier('CanonicalSchemaName')} = ${canonicalLiteral} ` +
690
+ `WHERE LOWER(${dialect.QuoteIdentifier('SchemaName')}) = LOWER(${physicalLiteral})`;
691
+ await context.DatabaseProvider.ExecuteSQL(sql);
692
+ }
693
+ catch (error) {
694
+ const message = error instanceof Error ? error.message : String(error);
695
+ context.Callbacks?.OnWarn?.('Schema', `Could not persist canonical schema name for '${canonicalName}' (will be backfilled at codegen time): ${message}`);
696
+ }
697
+ }
698
+ async function HandleMigrations(manifest, context, subpath) {
577
699
  if (!manifest.schema || !manifest.migrations) {
578
700
  return { Success: true };
579
701
  }
580
702
  context.Callbacks?.OnProgress?.('Migration', 'Downloading migration files...');
581
703
  const tempDir = join(tmpdir(), `mj-app-${manifest.name}-${Date.now()}`);
582
704
  mkdirSync(tempDir, { recursive: true });
583
- const downloadResult = await DownloadMigrations(manifest.repository, manifest.version, manifest.migrations.directory, tempDir, context.GitHubOptions);
705
+ const platform = context.DatabaseProvider.Dialect.PlatformKey;
706
+ const downloadResult = await DownloadAppMigrations(manifest, context, tempDir, subpath);
584
707
  if (!downloadResult.Success) {
585
708
  return { Success: false, ErrorMessage: downloadResult.ErrorMessage };
586
709
  }
@@ -591,11 +714,62 @@ async function HandleMigrations(manifest, context) {
591
714
  DatabaseConfig: context.DatabaseConfig,
592
715
  MJCoreSchema: context.MJCoreSchema,
593
716
  ExtraPlaceholders: context.MigrationPlaceholders,
594
- Platform: context.DatabaseProvider.Dialect.PlatformKey,
717
+ Platform: platform,
595
718
  });
596
719
  return { Success: migrationResult.Success, ErrorMessage: migrationResult.ErrorMessage };
597
720
  }
598
- async function HandlePackageInstallation(manifest, context, packageVersion, versionStrategy) {
721
+ async function HandleTeardown(manifest, context, subpath) {
722
+ const teardownDir = manifest.migrations?.teardownDirectory;
723
+ if (!teardownDir) {
724
+ return { Success: true };
725
+ }
726
+ const platform = context.DatabaseProvider.Dialect.PlatformKey;
727
+ const dir = platform === 'postgresql' ? `${teardownDir}-pg` : teardownDir;
728
+ const tempDir = join(tmpdir(), `mj-app-${manifest.name}-teardown-${Date.now()}`);
729
+ mkdirSync(tempDir, { recursive: true });
730
+ context.Callbacks?.OnProgress?.('Metadata', 'Downloading teardown scripts...');
731
+ const download = await DownloadMigrations(manifest.repository, manifest.version, dir, tempDir, context.GitHubOptions, subpath);
732
+ if (!download.Success) {
733
+ return { Success: false, ErrorMessage: `Failed to download teardown scripts: ${download.ErrorMessage}` };
734
+ }
735
+ const files = (download.Files ?? []).filter((f) => f.endsWith('.sql')).sort();
736
+ if (files.length === 0) {
737
+ context.Callbacks?.OnWarn?.('Metadata', `No teardown scripts in '${dir}' — this app's rows in the shared core schema will NOT be retired on remove.`);
738
+ return { Success: true };
739
+ }
740
+ const mjSchema = context.MJCoreSchema ?? '__mj';
741
+ context.Callbacks?.OnProgress?.('Metadata', `Running ${files.length} teardown script(s) against '${mjSchema}'...`);
742
+ await context.DatabaseProvider.BeginTransaction();
743
+ try {
744
+ for (const file of files) {
745
+ const sql = readFileSync(join(tempDir, file), 'utf-8').split('${mjSchema}').join(mjSchema);
746
+ if (sql.trim()) {
747
+ await context.DatabaseProvider.ExecuteSQL(sql);
748
+ }
749
+ }
750
+ await context.DatabaseProvider.CommitTransaction();
751
+ }
752
+ catch (error) {
753
+ await context.DatabaseProvider.RollbackTransaction();
754
+ const message = error instanceof Error ? error.message : String(error);
755
+ return { Success: false, ErrorMessage: `Teardown failed for '${manifest.name}' (rolled back): ${message}` };
756
+ }
757
+ context.Callbacks?.OnSuccess?.('Metadata', `Retired this app's rows from '${mjSchema}' (${files.length} teardown script(s)).`);
758
+ return { Success: true };
759
+ }
760
+ async function DownloadAppMigrations(manifest, context, tempDir, subpath) {
761
+ const baseDir = manifest.migrations.directory;
762
+ const isPG = context.DatabaseProvider.Dialect.PlatformKey === 'postgresql';
763
+ if (isPG) {
764
+ const pgDir = `${baseDir.replace(/\/+$/, '')}-pg`;
765
+ const pgResult = await DownloadMigrations(manifest.repository, manifest.version, pgDir, tempDir, context.GitHubOptions, subpath);
766
+ if (pgResult.Success && (pgResult.Files?.length ?? 0) > 0) {
767
+ return pgResult;
768
+ }
769
+ }
770
+ return DownloadMigrations(manifest.repository, manifest.version, baseDir, tempDir, context.GitHubOptions, subpath);
771
+ }
772
+ async function HandlePackageInstallation(manifest, context, packageVersion, versionStrategy, verbose) {
599
773
  if (!manifest.packages) {
600
774
  return { Success: true };
601
775
  }
@@ -624,7 +798,7 @@ async function HandlePackageInstallation(manifest, context, packageVersion, vers
624
798
  }
625
799
  }
626
800
  context.Callbacks?.OnProgress?.('Packages', 'Running package install...');
627
- const installResult = RunPackageInstall(context.RepoRoot, undefined, manifest.packages.registry, context.PackageManager);
801
+ const installResult = RunPackageInstall(context.RepoRoot, verbose, manifest.packages.registry, context.PackageManager);
628
802
  if (!installResult.Success) {
629
803
  return { Success: false, PackageJsonUpdated: true, ErrorMessage: installResult.ErrorMessage };
630
804
  }
@@ -636,6 +810,10 @@ function HandleServerConfig(manifest, context) {
636
810
  if (!dynamicResult.Success) {
637
811
  return { Success: false, ErrorMessage: dynamicResult.ErrorMessage };
638
812
  }
813
+ const clientResult = AddClientDynamicPackages(context.RepoRoot, manifest);
814
+ if (!clientResult.Success) {
815
+ return { Success: false, ErrorMessage: clientResult.ErrorMessage };
816
+ }
639
817
  const entityResult = AddEntityPackageMapping(context.RepoRoot, manifest);
640
818
  if (!entityResult.Success) {
641
819
  return { Success: false, ErrorMessage: entityResult.ErrorMessage };
@@ -664,67 +842,20 @@ function HandleAngularPrebundleExcludeRemoval(manifest, otherManifests, context)
664
842
  manager.RemovePrebundleExcludes(manifest, otherManifests);
665
843
  manager.Save();
666
844
  }
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
845
  async function ExecuteHook(command, cwd) {
717
846
  const { execSync } = await import('node:child_process');
718
847
  execSync(command, { cwd, encoding: 'utf-8', timeout: 120000, stdio: 'inherit' });
719
848
  }
720
- async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
849
+ export async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks, provider) {
721
850
  try {
722
851
  const rv = new RunView();
723
852
  const escaped = EscapeSqlString(schemaName);
724
- const deleteByFilterOrThrow = async (entityName, filter) => {
725
- const r = await DeleteEntitiesByFilter(rv, contextUser, entityName, filter);
853
+ const md = (provider ?? new Metadata());
854
+ const tg = await md.CreateTransactionGroup();
855
+ const queueDeleteByFilterOrThrow = async (entityName, filter) => {
856
+ const r = await QueueDeleteEntitiesByFilter(rv, contextUser, entityName, filter, tg);
726
857
  if (!r.Success) {
727
- throw new Error(r.ErrorMessage ?? `Failed to delete ${entityName} records`);
858
+ throw new Error(r.ErrorMessage ?? `Failed to queue delete of ${entityName} records`);
728
859
  }
729
860
  };
730
861
  const entityResult = await rv.RunView({
@@ -736,11 +867,14 @@ async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
736
867
  throw new Error(`Failed to query entities for schema '${schemaName}': ${entityResult.ErrorMessage}`);
737
868
  }
738
869
  if (entityResult.Results.length === 0) {
739
- await deleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
870
+ await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
871
+ if (!(await tg.Submit())) {
872
+ throw new Error('Transaction failed: schema-info cleanup could not be committed');
873
+ }
740
874
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
741
875
  return { Success: true };
742
876
  }
743
- const entityIds = entityResult.Results.map((e) => String(e.Get('ID')));
877
+ const entityIds = entityResult.Results.map((e) => e.ID);
744
878
  const idList = entityIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
745
879
  const fieldResult = await rv.RunView({
746
880
  EntityName: 'MJ: Entity Fields',
@@ -750,21 +884,25 @@ async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
750
884
  if (!fieldResult.Success) {
751
885
  throw new Error(`Failed to query entity fields for schema '${schemaName}': ${fieldResult.ErrorMessage}`);
752
886
  }
753
- const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(String(f.Get('ID')))}'`).join(',');
887
+ const fieldIdList = fieldResult.Results.map((f) => `'${EscapeSqlString(f.ID)}'`).join(',');
888
+ const ownedAppIds = await FindAppOwnedApplications(rv, contextUser, entityIds, idList);
754
889
  if (fieldIdList.length > 0) {
755
- await deleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
890
+ await queueDeleteByFilterOrThrow('MJ: Entity Field Values', `EntityFieldID IN (${fieldIdList})`);
756
891
  }
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})`);
892
+ await queueDeleteByFilterOrThrow('MJ: Entity Permissions', `EntityID IN (${idList})`);
893
+ await queueDeleteByFilterOrThrow('MJ: Application Entities', `EntityID IN (${idList})`);
894
+ await queueDeleteByFilterOrThrow('MJ: Entity Settings', `EntityID IN (${idList})`);
895
+ await queueDeleteByFilterOrThrow('MJ: Entity Relationships', `EntityID IN (${idList}) OR RelatedEntityID IN (${idList})`);
896
+ await queueDeleteByFilterOrThrow('MJ: Entity Fields', `EntityID IN (${idList})`);
762
897
  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
- }
898
+ entity.TransactionGroup = tg;
899
+ await entity.Delete();
900
+ }
901
+ await queueDeleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
902
+ if (!(await tg.Submit())) {
903
+ throw new Error('Transaction failed: entity metadata cleanup could not be committed atomically');
766
904
  }
767
- await deleteByFilterOrThrow('MJ: Schema Info', `SchemaName = '${escaped}'`);
905
+ await DeleteAppOwnedApplications(rv, ownedAppIds, contextUser, callbacks);
768
906
  callbacks?.OnSuccess?.('Metadata', `Entity metadata for schema '${schemaName}' removed`);
769
907
  return { Success: true };
770
908
  }
@@ -774,7 +912,49 @@ async function RemoveAppEntityMetadata(schemaName, contextUser, callbacks) {
774
912
  return { Success: false, ErrorMessage: `Failed to remove entity metadata for schema '${schemaName}': ${message}` };
775
913
  }
776
914
  }
777
- async function DeleteEntitiesByFilter(rv, contextUser, entityName, filter) {
915
+ async function FindAppOwnedApplications(rv, contextUser, entityIds, idList) {
916
+ const ours = new Set(entityIds.map((id) => NormalizeUUID(id)));
917
+ const linked = await rv.RunView({ EntityName: 'MJ: Application Entities', ExtraFilter: `EntityID IN (${idList})`, Fields: ['ApplicationID'], ResultType: 'simple' }, contextUser);
918
+ if (!linked.Success) {
919
+ return [];
920
+ }
921
+ const candidateIds = [...new Set(linked.Results.map((r) => r.ApplicationID))];
922
+ if (candidateIds.length === 0) {
923
+ return [];
924
+ }
925
+ const candList = candidateIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
926
+ const allLinks = await rv.RunView({ EntityName: 'MJ: Application Entities', ExtraFilter: `ApplicationID IN (${candList})`, Fields: ['ApplicationID', 'EntityID'], ResultType: 'simple' }, contextUser);
927
+ if (!allLinks.Success) {
928
+ return [];
929
+ }
930
+ const allOursByApp = new Map();
931
+ for (const link of allLinks.Results) {
932
+ const wasAllOurs = allOursByApp.get(link.ApplicationID) ?? true;
933
+ allOursByApp.set(link.ApplicationID, wasAllOurs && ours.has(NormalizeUUID(link.EntityID)));
934
+ }
935
+ return [...allOursByApp.entries()].filter(([, allOurs]) => allOurs).map(([appId]) => appId);
936
+ }
937
+ async function DeleteAppOwnedApplications(rv, applicationIds, contextUser, callbacks) {
938
+ if (applicationIds.length === 0) {
939
+ return;
940
+ }
941
+ const idList = applicationIds.map((id) => `'${EscapeSqlString(id)}'`).join(',');
942
+ const appsResult = await rv.RunView({ EntityName: 'MJ: Applications', ExtraFilter: `ID IN (${idList})`, ResultType: 'entity_object' }, contextUser);
943
+ if (!appsResult.Success) {
944
+ return;
945
+ }
946
+ for (const app of appsResult.Results) {
947
+ try {
948
+ if (!(await app.Delete())) {
949
+ callbacks?.OnWarn?.('Metadata', `Left Application '${app.Name}' — it still has dependents; a reinstall will reuse it (${app.LatestResult?.CompleteMessage ?? 'has FK references'}).`);
950
+ }
951
+ }
952
+ catch (error) {
953
+ callbacks?.OnWarn?.('Metadata', `Could not remove app-owned Application '${app.Name}': ${error instanceof Error ? error.message : String(error)}`);
954
+ }
955
+ }
956
+ }
957
+ async function QueueDeleteEntitiesByFilter(rv, contextUser, entityName, filter, transactionGroup) {
778
958
  const result = await rv.RunView({
779
959
  EntityName: entityName,
780
960
  ExtraFilter: filter,
@@ -784,9 +964,8 @@ async function DeleteEntitiesByFilter(rv, contextUser, entityName, filter) {
784
964
  return { Success: false, ErrorMessage: `Failed to query ${entityName}: ${result.ErrorMessage}` };
785
965
  }
786
966
  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
- }
967
+ record.TransactionGroup = transactionGroup;
968
+ await record.Delete();
790
969
  }
791
970
  return { Success: true };
792
971
  }