Skip to content

Commit bb1a205

Browse files
akshay-vizCopilot
andcommitted
fix(model-apps): never delete a generative page's files ourselves
Review caught a real data-loss path in the genpage teardown step: it deleted the uxagentprojectfile rows first and the uxagentproject row second. If the project delete was then dependency-blocked and skipped, the files were already gone -- leaving the app that still referenced the page pointing at an empty shell. Measured against a live environment, which is what makes this concrete: - The project row IS dependency-tracked (component type 10372). On pages an app sitemap references it reports 1 dependent and DELETE returns 400 "cannot be deleted because it is referenced by 1 other components". - Its files are NOT. Across 20 files of 5 such referenced pages, RetrieveDependenciesForDelete returned 0 for every single one, so each would have deleted cleanly. - The uxagentproject -> uxagentprojectfile relationship is CascadeConfiguration Delete=Cascade, so the file loop was never needed in the first place. The fix is therefore a deletion: delete only the project row and let the platform cascade. A dependency block now happens before anything has been written, so a skipped page is left completely intact. This also removes a second defect the reviewer flagged -- a not-found thrown mid-loop aborted before the project delete while deleteStep recorded the whole page as successfully deleted, reporting a page gone that was still there. Both regression tests were verified red against the old ordering before being taken green; the skip test deliberately supplies files so it cannot pass vacuously. Also removes stale comments and test scaffolding describing the cross-app sitemap scan that was replaced by platform arbitration, and corrects an assertion message that still claimed deleteAppCascade removes genpages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 731c1111-d9b6-4fd7-b9d3-347f762ca7f5
1 parent 4eb1632 commit bb1a205

2 files changed

Lines changed: 67 additions & 61 deletions

File tree

plugins/model-apps/scripts/lib/sdk-teardown.js

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
// page is REFERENCED by an app, not owned by one — another app's sitemap, or a
1414
// form's UxAgentControl `RefId` in formxml, can point at the same row — so the
1515
// SDK reports them and the owner decides. Runs AFTER the app so the app's own
16-
// sitemap reference is gone and the cross-app scan only sees genuine other
17-
// consumers; a page any other app still references is SKIPPED, not deleted.
16+
// sitemap reference is already gone and the only dependency the platform can
17+
// still report is a GENUINE other consumer; such a page is SKIPPED, not deleted.
1818
// 1b. roles — persona security roles. Deleted right after the app (BEFORE the data model): a
1919
// role holding a soon-to-be-deleted table's privileges could otherwise block that
2020
// table's delete. SEC-1: only roles the SDK itself authored (marked on the role
@@ -220,21 +220,18 @@ const KIND_HANDLERS = {
220220
return [];
221221
}
222222
},
223-
// Files first, then the project — the parent cannot be removed while children reference it.
224-
// A page still in use fails here with a dependency error, which `deleteStep` records as a SKIP
225-
// (see isUndeletable) rather than a teardown failure: another app legitimately owning the page
226-
// is an expected outcome, not a broken teardown.
223+
// Delete ONLY the project row. Its `uxagentprojectfile` children go with it: the
224+
// uxagentproject_uxagentprojectfile_uxagentprojectid relationship is CascadeConfiguration
225+
// Delete=Cascade, so the platform removes them for us.
226+
//
227+
// Deleting the files ourselves first would be actively DESTRUCTIVE. Dataverse tracks a
228+
// dependency on the PROJECT row (component type 10372) but NOT on its files (10373):
229+
// measured on pages that an app sitemap references, the project reports 1 dependent and its
230+
// DELETE is refused, while every one of its files reports ZERO dependents and would delete
231+
// cleanly. So a files-first order would strip the content out of a page the platform is about
232+
// to refuse to delete, leaving the app that still references it pointing at an empty shell —
233+
// exactly the data loss this step exists to avoid. One delete, and the platform decides.
227234
async del(sdk, item) {
228-
const files = await sdk.queryRecords('uxagentprojectfile', {
229-
select: ['uxagentprojectfileid'],
230-
filter: `_uxagentprojectid_value eq ${item.id}`,
231-
paginate: true,
232-
});
233-
for (const f of files || []) {
234-
if (f && f.uxagentprojectfileid) {
235-
await sdk.deleteRecord('uxagentprojectfile', f.uxagentprojectfileid);
236-
}
237-
}
238235
await sdk.deleteRecord('uxagentproject', item.id);
239236
},
240237
},
@@ -455,10 +452,10 @@ function planTeardown(spec) {
455452
// Generative pages, AFTER the app. The SDK no longer deletes them — a page is
456453
// referenced by an app, not owned by one, so the SDK reports them and the owner decides. We are
457454
// the owner: the page manifest records exactly which pages this build authored. Ordered after the
458-
// app so the app's own sitemap reference is already gone and the cross-app scan only ever sees a
459-
// GENUINE other consumer. Emitted for every app-bearing spec (not gated on spec.pages) so a spec
460-
// that dropped its pages still cleans up what it previously created; resolve is a no-op when the
461-
// manifest is absent or lists nothing.
455+
// app so the app's own sitemap reference is already gone and any dependency the platform still
456+
// reports belongs to a GENUINE other consumer. Emitted for every app-bearing spec (not gated on
457+
// spec.pages) so a spec that dropped its pages still cleans up what it previously created;
458+
// resolve is a no-op when the manifest is absent or lists nothing.
462459
steps.push({
463460
kind: 'genpage',
464461
phase: 'pages',

plugins/model-apps/scripts/tests/sdk-teardown.test.js

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ test('app teardown resolves via resolveArtifact and delegates the full cascade t
447447
await h.del(sdk, items[0]);
448448
assert.strictEqual(cascadeCalls.length, 1, 'deleteAppCascade called once');
449449
assert.strictEqual(cascadeCalls[0].appModuleId, 'app-1', 'app module id passed');
450-
assert.strictEqual(cascadeCalls[0].appModuleIdUnique, 'u-1', 'unique id passed (deleteAppCascade handles sitemap + genpage internally)');
450+
assert.strictEqual(cascadeCalls[0].appModuleIdUnique, 'u-1', 'unique id passed (deleteAppCascade removes the sitemap; generative pages are a separate step)');
451451
});
452452

453453
test('app teardown skips when app not found (resolve returns [])', async () => {
@@ -992,19 +992,18 @@ test('planTeardown trims the persona name in the role step (matches the SDK-crea
992992
// owns that decision for the pages IT authored. These pin who deletes what, and when it backs off.
993993
// ---------------------------------------------------------------------------------------------
994994

995-
// A minimal SDK stand-in for the genpage handler: a page manifest in a web resource, the live
996-
// uxagentproject/file rows, and the appmodule sitemaps the cross-app scan reads.
997-
// A minimal SDK stand-in for the genpage handler: a page manifest in a web resource, the live
998-
// uxagentproject/file rows, and the OTHER apps + sitemaps the cross-app scan walks.
999-
// `otherApps` is [{ unique, xml }] — the scan enumerates appmodules, then reads each one's sitemap
1000-
// via appmodulecomponent (componenttype 62) -> sitemap row. A real env ALWAYS has system apps, and
1001-
// fetchAppsForPages fail-closes on an empty enumeration, so every case supplies at least one.
1002-
function genpageSdk({ manifestPages = [], livePages = [], files = {}, otherApps = [{ unique: 'system_app', xml: '<SiteMap />' }], sitemapThrows = false } = {}) {
995+
// A minimal SDK stand-in for the genpage handler: the page manifest (in a web resource) that says
996+
// which pages this build authored, and the live `uxagentproject` rows used to skip ones already
997+
// gone. The `uxagentprojectfile` branch exists ONLY so tests can prove the handler never touches
998+
// it — deleting the project cascades to its files, and deleting them ourselves would gut a page
999+
// the platform is about to refuse to delete.
1000+
function genpageSdk({ manifestPages = [], livePages = [], files = {} } = {}) {
10031001
const deleted = [];
1002+
const queried = [];
10041003
const manifestJson = JSON.stringify({ schemaVersion: 1, pages: manifestPages });
1005-
const byUnique = new Map(otherApps.map((a, i) => [a.unique, { i, xml: a.xml }]));
10061004
const sdk = {
10071005
async queryRecords(entity, opts = {}) {
1006+
queried.push(entity);
10081007
const filter = String(opts.filter || '');
10091008
if (entity === 'webresource') {
10101009
return [{ content: Buffer.from(manifestJson, 'utf8').toString('base64') }];
@@ -1016,33 +1015,13 @@ function genpageSdk({ manifestPages = [], livePages = [], files = {}, otherApps
10161015
const owner = (/_uxagentprojectid_value eq ([0-9a-f-]+)/i.exec(filter) || [])[1] || '';
10171016
return (files[owner.toLowerCase()] || []).map((id) => ({ uxagentprojectfileid: id }));
10181017
}
1019-
if (entity === 'appmodule') {
1020-
const m = /uniquename eq '([^']+)'/.exec(filter);
1021-
if (m) {
1022-
const hit = byUnique.get(m[1]);
1023-
return hit ? [{ appmoduleid: `app-${hit.i}`, appmoduleidunique: `uniq-${hit.i}` }] : [];
1024-
}
1025-
// The env-wide enumeration.
1026-
return otherApps.map((a, i) => ({ appmoduleid: `app-${i}`, appmoduleidunique: `uniq-${i}`, uniquename: a.unique }));
1027-
}
1028-
if (entity === 'appmodulecomponent') {
1029-
const m = /_appmoduleidunique_value eq (\S+)/.exec(filter);
1030-
return m ? [{ objectid: `sm-${m[1]}`, componenttype: 62 }] : [];
1031-
}
1032-
if (entity === 'sitemap') {
1033-
if (sitemapThrows) throw new Error('sitemap unreadable');
1034-
const m = /sitemapid eq ([\w-]+)/.exec(filter) || /sm-uniq-(\d+)/.exec(filter);
1035-
const idx = m ? Number(String(m[1]).replace(/\D/g, '')) : 0;
1036-
const app = otherApps[idx];
1037-
return app ? [{ sitemapxml: app.xml }] : [];
1038-
}
10391018
return [];
10401019
},
10411020
async deleteRecord(entity, id) {
10421021
deleted.push(`${entity}:${id}`);
10431022
},
10441023
};
1045-
return { sdk, deleted };
1024+
return { sdk, deleted, queried };
10461025
}
10471026
const PAGE_1 = '11111111-1111-4111-8111-111111111111';
10481027
const PAGE_2 = '22222222-2222-4222-8222-222222222222';
@@ -1056,14 +1035,14 @@ test('genpage resolve returns only pages the manifest says WE authored', async (
10561035
assert.deepStrictEqual(items.map((i) => i.id), [PAGE_1]);
10571036
});
10581037

1059-
test('genpage del removes the file rows before the project', async () => {
1060-
const { sdk, deleted } = genpageSdk({ files: { [PAGE_1.toLowerCase()]: ['f1', 'f2'] } });
1038+
// Deleting the project cascades to its files (the uxagentproject -> uxagentprojectfile
1039+
// relationship is CascadeConfiguration Delete=Cascade), so ONE delete is both necessary and
1040+
// sufficient. Deleting files ourselves first would be destructive — see the skip test below.
1041+
test('genpage del deletes ONLY the project row and never touches its files', async () => {
1042+
const { sdk, deleted, queried } = genpageSdk({ files: { [PAGE_1.toLowerCase()]: ['f1', 'f2'] } });
10611043
await KIND_HANDLERS.genpage.del(sdk, { id: PAGE_1, name: 'Overview' });
1062-
assert.deepStrictEqual(deleted, [
1063-
'uxagentprojectfile:f1',
1064-
'uxagentprojectfile:f2',
1065-
`uxagentproject:${PAGE_1}`,
1066-
]);
1044+
assert.deepStrictEqual(deleted, [`uxagentproject:${PAGE_1}`]);
1045+
assert.ok(!queried.includes('uxagentprojectfile'), 'must not enumerate the page files');
10671046
});
10681047

10691048
// Dataverse is the authority on whether a page is still in use: saving an app that surfaces a page
@@ -1087,11 +1066,41 @@ test('a dependency block is still a FAILURE for kinds that did not opt in', asyn
10871066
await assert.rejects(() => deleteStep({}, handler, [{ id: 'v1' }]), /referenced by/);
10881067
});
10891068

1090-
test('genpage deletes a page no other app references', async () => {
1069+
// THE regression this ordering exists for. Dataverse tracks a dependency on the page ROW
1070+
// (component type 10372) but NOT on its files (10373): measured on pages an app sitemap
1071+
// references, the project reports 1 dependent and its DELETE is refused, while every one of its
1072+
// files reports ZERO dependents and would delete cleanly. So if the handler deleted files first,
1073+
// a skipped page would be left as an empty shell for the app that still references it.
1074+
test('a still-referenced page is SKIPPED with its files left completely intact', async () => {
1075+
const err = new Error('The uxagentproject(11111111) component cannot be deleted because it is referenced by 1 other components.');
1076+
const deleted = [];
1077+
const sdk = {
1078+
// The page HAS files — a handler that enumerated and deleted them first would destroy the
1079+
// content of a page the platform then refuses to delete.
1080+
async queryRecords(entity) {
1081+
return entity === 'uxagentprojectfile'
1082+
? [{ uxagentprojectfileid: 'f1' }, { uxagentprojectfileid: 'f2' }]
1083+
: [];
1084+
},
1085+
async deleteRecord(entity, id) {
1086+
deleted.push(`${entity}:${id}`);
1087+
if (entity === 'uxagentproject') throw err;
1088+
},
1089+
};
1090+
const r = await deleteStep(sdk, KIND_HANDLERS.genpage, [{ id: PAGE_1, name: 'Overview' }]);
1091+
assert.deepStrictEqual(r.skippedIds, [PAGE_1]);
1092+
assert.deepStrictEqual(r.deletedIds, []);
1093+
assert.deepStrictEqual(
1094+
deleted,
1095+
[`uxagentproject:${PAGE_1}`],
1096+
'the page delete must be the ONLY write attempted — its files must survive the skip'
1097+
);
1098+
});
1099+
1100+
test('genpage resolve ignores live pages the manifest does not claim', async () => {
10911101
const { sdk } = genpageSdk({
10921102
manifestPages: [{ key: 'overview', name: 'Overview', pageId: PAGE_1 }],
1093-
livePages: [PAGE_1],
1094-
otherApps: [{ unique: 'other_app', xml: `<SiteMap><Area><Group><SubArea GenPageId="${PAGE_2}" /></Group></Area></SiteMap>` }],
1103+
livePages: [PAGE_1, PAGE_2],
10951104
});
10961105
const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' });
10971106
assert.deepStrictEqual(items.map((i) => i.id), [PAGE_1]);

0 commit comments

Comments
 (0)