88//
99// Order (each the mirror of the build's create order — dependents before their dependencies):
1010// 1. app — the app module (references the sitemap + dashboard/form/view/chart components)
11+ // 1a. pages — generative pages (uxagentproject + files) this build AUTHORED, per the page
12+ // manifest. The SDK's deleteAppCascade no longer removes them: a
13+ // page is REFERENCED by an app, not owned by one — another app's sitemap, or a
14+ // form's UxAgentControl `RefId` in formxml, can point at the same row — so the
15+ // SDK reports them and the owner decides. Runs AFTER the app so the app's own
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.
1118// 1b. roles — persona security roles. Deleted right after the app (BEFORE the data model): a
1219// role holding a soon-to-be-deleted table's privileges could otherwise block that
1320// table's delete. SEC-1: only roles the SDK itself authored (marked on the role
3946
4047const { topoOrderEntities } = require ( './_graph.js' ) ;
4148const { appUniqueName, commandsByEntity, defaultViewColumns, resolveExistingFormId, resolveRoleBusinessUnit, roleBuClause } = require ( './sdk-build.js' ) ;
42- const { manifestResourceName } = require ( './page-manifest.js' ) ;
49+ const { manifestResourceName, parseManifestBase64 } = require ( './page-manifest.js' ) ;
4350const { relationshipSchemaName, manyToManySchemaName, lookupColumnsFor, SDK_ROLE_MARKER , canonicalPersonaName, FORM_GUID_RE } = require ( './app-spec.js' ) ;
4451const { selectSummaryTables } = require ( './ai-candidates.js' ) ;
45- const { isRestrictedSolution } = require ( './system-solutions.js' ) ;
52+ const { isRestrictedSolution } = require ( './system-solutions.js' ) ;
4653
4754// OData v4 string-literal escaping lives in ./odata.js. `odataStr` is kept as a backward-compatible
4855// alias because it is part of this module's exported (and unit-tested) surface.
@@ -74,6 +81,17 @@ function isNotFound(err) {
7481// teardown skips it instead of failing — the same best-effort spirit as isNotFound. Deliberately
7582// NARROW: it must NOT match a dependency block ("...cannot be deleted because it is referenced by
7683// N other components"), which is a genuine leftover the teardown must surface, not swallow.
84+ // Dataverse refused a delete because another component still references the record:
85+ // "The <entity>(<id>) component cannot be deleted because it is referenced by N other components."
86+ // For MOST kinds that is a genuine leftover the teardown must surface. For a generative page it is
87+ // the correct, expected answer — the page belongs to whoever still points at it — so only handlers
88+ // that opt in via `tolerateDependencyBlock` treat it as a skip.
89+ function isDependencyBlocked ( err ) {
90+ if ( ! err ) return false ;
91+ const msg = String ( ( err && err . message ) || '' ) . toLowerCase ( ) ;
92+ return / c a n n o t b e d e l e t e d b e c a u s e i t i s r e f e r e n c e d b y / . test ( msg ) || / r e f e r e n c e d b y \d + o t h e r c o m p o n e n t / . test ( msg ) ;
93+ }
94+
7795function isUndeletable ( err ) {
7896 if ( ! err ) return false ;
7997 const msg = String ( ( err && err . message ) || '' ) . toLowerCase ( ) ;
@@ -120,15 +138,18 @@ const KIND_HANDLERS = {
120138 const items = await sdk . resolveArtifact ( 'app' , { uniqueName : target . uniqueName } ) ;
121139 return ( items || [ ] ) . map ( ( x ) => ( { id : x . id , name : x . name , appModuleIdUnique : x . appModuleIdUnique } ) ) ;
122140 } ,
123- // deleteAppCascade fail-fast-deletes the app module, then best-effort cleans up the
124- // orphaned sitemap + generative-page rows (uxagentproject[file]). It returns a structured
125- // { success, deleted, failures } result (older vendored bundles returned void). The app
126- // record itself is gone once this resolves, but an individual child-cleanup step can still
127- // fail — which the old void contract swallowed, silently leaving orphaned rows while the
128- // teardown reported a clean delete. Surface any GENUINE child failure so the run reports
129- // ok=false with the exact leftovers. A not-found child failure means the row already
130- // cascaded away (not a leftover), so it is tolerated — the same best-effort spirit as the
131- // step-level isNotFound handling in deleteStep.
141+ // deleteAppCascade fail-fast-deletes the app module together with its sitemap (atomically), and
142+ // returns a structured { success, deleted, failures, retained } result (older vendored bundles
143+ // returned void). It deliberately does NOT delete the app's generative pages — a `uxagentproject`
144+ // is referenced by an app, not owned by one, so it reports them in `retained` and the owner
145+ // decides. The `genpage` step that follows is that decision: it deletes the pages
146+ // THIS build authored, per the page manifest, skipping any another app still references.
147+ //
148+ // The app record itself is gone once this resolves, but a cleanup step can still fail — which the
149+ // old void contract swallowed, silently leaving orphaned rows while teardown reported a clean
150+ // delete. Surface any GENUINE failure so the run reports ok=false with the exact leftovers. A
151+ // not-found failure means the row already cascaded away (not a leftover), so it is tolerated —
152+ // the same best-effort spirit as the step-level isNotFound handling in deleteStep.
132153 async del ( sdk , item ) {
133154 const result = await sdk . deleteAppCascade ( item . id , item . appModuleIdUnique ) ;
134155 const failures = ( result && Array . isArray ( result . failures ) ? result . failures : [ ] ) . filter (
@@ -144,6 +165,86 @@ const KIND_HANDLERS = {
144165 }
145166 } ,
146167 } ,
168+ // Generative pages the build authored. The SDK's `deleteAppCascade` deliberately does NOT delete
169+ // these: a `uxagentproject` is REFERENCED by an app, not owned by one, so the SDK
170+ // reports them in `retained` and leaves the decision to the caller. WE are the caller that CREATED
171+ // them, and the page manifest is the durable record of exactly which pages this build authored —
172+ // so teardown deletes those, and only those.
173+ //
174+ // Safety is delegated to DATAVERSE, not inferred from a scan. Verified against a live environment:
175+ // saving an app that surfaces a page creates a real solution dependency, and DELETE on that page
176+ // returns 400 "component cannot be deleted because it is referenced by N other components" —
177+ // whether or not the app is published. The dependency clears only when the referencing sitemap is
178+ // removed AND published, or when the app+sitemap are deleted outright (which is what the step
179+ // before this one just did).
180+ //
181+ // So the delete IS the check. Attempting it and reading the platform's answer beats a pre-flight
182+ // scan: it is authoritative (the platform's own dependency graph, not our model of it), and it has
183+ // no TOCTOU window — a pre-check can go stale between the check and the delete, this cannot.
184+ //
185+ // KNOWN GAP, measured rather than assumed: that graph covers SITEMAP references only. A page
186+ // embedded in a FORM through the `MscrmControls.UxAgentControl` PCF is NOT tracked — a form was
187+ // built with a page in its `RefId`, saved and published, and the page still reported ZERO
188+ // dependents and deleted with a 204. The form's own required-components list names the PCF
189+ // (component type 66) and never the page, because `RefId` is an opaque
190+ // `static="true" type="SingleLine.Text"` value the platform cannot know is a reference.
191+ // We accept that gap here because this step only ever deletes pages THIS build authored and is
192+ // tearing down the app that owns them; it is not closable by asking the platform, and a formxml
193+ // scan is the only thing that would close it.
194+ genpage : {
195+ // A page another app still references is a SKIP, not a failure — see isDependencyBlocked.
196+ tolerateDependencyBlock : true ,
197+ async resolve ( sdk , target ) {
198+ if ( typeof sdk . queryRecords !== 'function' ) return [ ] ;
199+ // The manifest lives in a web resource this same teardown deletes later (web-resources phase),
200+ // so it is still readable here.
201+ let manifest = null ;
202+ try {
203+ const rows = await sdk . queryRecords ( 'webresource' , {
204+ select : [ 'content' ] ,
205+ filter : `name eq '${ odataStr ( target . manifestName ) } '` ,
206+ top : 1 ,
207+ } ) ;
208+ if ( rows && rows [ 0 ] && rows [ 0 ] . content ) manifest = parseManifestBase64 ( rows [ 0 ] . content ) ;
209+ } catch {
210+ // No manifest readable → nothing provably ours → delete nothing. Leaving a row behind is
211+ // recoverable; deleting a page we cannot prove we authored is not.
212+ return [ ] ;
213+ }
214+ const authored = [ ] ;
215+ for ( const p of ( manifest && manifest . pages ) || [ ] ) {
216+ if ( p && typeof p . pageId === 'string' && FORM_GUID_RE . test ( p . pageId ) ) {
217+ authored . push ( { id : p . pageId , name : p . name || p . key || p . pageId } ) ;
218+ }
219+ }
220+ if ( ! authored . length ) return [ ] ;
221+
222+ // Only pages that still exist (a re-run, or a maker deleting one by hand, is not a failure).
223+ try {
224+ const filter = authored . map ( ( a ) => `uxagentprojectid eq ${ String ( a . id ) . toLowerCase ( ) } ` ) . join ( ' or ' ) ;
225+ const rows = await sdk . queryRecords ( 'uxagentproject' , { select : [ 'uxagentprojectid' ] , filter } ) ;
226+ const live = new Set ( ( rows || [ ] ) . map ( ( r ) => String ( r . uxagentprojectid ) . toLowerCase ( ) ) ) ;
227+ return authored . filter ( ( a ) => live . has ( String ( a . id ) . toLowerCase ( ) ) ) ;
228+ } catch {
229+ return [ ] ;
230+ }
231+ } ,
232+ // Delete ONLY the project row. Its `uxagentprojectfile` children go with it: the
233+ // uxagentproject_uxagentprojectfile_uxagentprojectid relationship is CascadeConfiguration
234+ // Delete=Cascade, verified end to end — one DELETE of a real pac-created page removed the
235+ // project and all four of its file rows, none orphaned.
236+ //
237+ // Deleting the files ourselves first would be actively DESTRUCTIVE. Dataverse tracks a
238+ // dependency on the PROJECT row (component type 10372) but NOT on its files (10373):
239+ // measured on pages that an app sitemap references, the project reports 1 dependent and its
240+ // DELETE is refused, while every one of its files reports ZERO dependents and would delete
241+ // cleanly. So a files-first order would strip the content out of a page the platform is about
242+ // to refuse to delete, leaving the app that still references it pointing at an empty shell —
243+ // exactly the data loss this step exists to avoid. One delete, and the platform decides.
244+ async del ( sdk , item ) {
245+ await sdk . deleteRecord ( 'uxagentproject' , item . id ) ;
246+ } ,
247+ } ,
147248 dashboard : {
148249 async resolve ( sdk , target ) {
149250 const items = await sdk . resolveArtifact ( 'dashboard' , { name : target . name } ) ;
@@ -358,6 +459,19 @@ function planTeardown(spec) {
358459 const steps = [ ] ;
359460 if ( spec . app && spec . solution ) {
360461 steps . push ( { kind : 'app' , phase : 'app' , label : `app module "${ spec . app . name } "` , target : { uniqueName : appUniqueName ( spec ) } } ) ;
462+ // Generative pages, AFTER the app. The SDK no longer deletes them — a page is
463+ // referenced by an app, not owned by one, so the SDK reports them and the owner decides. We are
464+ // the owner: the page manifest records exactly which pages this build authored. Ordered after the
465+ // app so the app's own sitemap reference is already gone and any dependency the platform still
466+ // reports belongs to a GENUINE other consumer. Emitted for every app-bearing spec (not gated on
467+ // spec.pages) so a spec that dropped its pages still cleans up what it previously created;
468+ // resolve is a no-op when the manifest is absent or lists nothing.
469+ steps . push ( {
470+ kind : 'genpage' ,
471+ phase : 'pages' ,
472+ label : 'generative pages authored by this app' ,
473+ target : { manifestName : manifestResourceName ( appUniqueName ( spec ) ) } ,
474+ } ) ;
361475 }
362476 // Persona security roles — deleted right after the app, before the data model (a role holding a
363477 // table's privileges could block that table's delete). The role handler is SEC-1 safe (marker-gated)
@@ -489,13 +603,21 @@ function planTeardown(spec) {
489603}
490604
491605// Delete the resolved artifacts for one plan step via SDK methods. Returns `{ deletedIds,
492- // skippedIds }` — `skippedIds` are artifacts that exist but cannot be removed (system/managed),
493- // surfaced so a destructive run is auditable rather than silently reporting "(0 deleted)". A
494- // not-found error counts as already-gone. Throws only on a genuine failure (non-not-found,
495- // non-undeletable).
606+ // skippedIds, skipped }` — artifacts that exist but were not removed, surfaced so a destructive run
607+ // is auditable rather than silently reporting "(0 deleted)". `skipped` carries a REASON per id
608+ // because the two cases mean opposite things to an operator:
609+ //
610+ // - `undeletable` — a system/managed artifact that can never be removed. Nothing to act on.
611+ // - `referenced` — the platform refused because something else still points at it. The record
612+ // is perfectly deletable once that consumer releases it; for a generative page
613+ // this is the CORRECT, expected outcome, not a defect.
614+ //
615+ // Reporting both as "undeletable" would send an operator hunting a platform problem that isn't
616+ // there. `skippedIds` is retained as the union of both for callers that only need the count.
617+ // A not-found error counts as already-gone. Throws only on a genuine failure.
496618async function deleteStep ( sdk , handler , items ) {
497619 const deletedIds = [ ] ;
498- const skippedIds = [ ] ;
620+ const skipped = [ ] ;
499621 for ( const item of items ) {
500622 try {
501623 await handler . del ( sdk , item ) ;
@@ -511,16 +633,26 @@ async function deleteStep(sdk, handler, items) {
511633 deletedIds . push ( item . id ) ;
512634 continue ;
513635 }
636+ if ( handler . tolerateDependencyBlock && isDependencyBlocked ( err ) ) {
637+ // The platform refused because something else still references this record. For a
638+ // generative page that is the CORRECT outcome, not a leftover: the page belongs to whoever
639+ // still points at it, and Dataverse is the authority on that (live-measured — saving an app
640+ // that surfaces a page creates the dependency, published or not). Recorded as `referenced`
641+ // rather than `undeletable` so the run stays auditable AND the operator is told the truth:
642+ // nothing is broken, someone else is still using it.
643+ skipped . push ( { id : item . id , reason : 'referenced' } ) ;
644+ continue ;
645+ }
514646 if ( isUndeletable ( err ) ) {
515647 // A system/managed artifact (e.g. an auto-generated "Active <Entity>" view that shares
516648 // the spec view's name) — not ours to remove. Record it as skipped without failing.
517- skippedIds . push ( item . id ) ;
649+ skipped . push ( { id : item . id , reason : 'undeletable' } ) ;
518650 continue ;
519651 }
520652 throw err ;
521653 }
522654 }
523- return { deletedIds, skippedIds } ;
655+ return { deletedIds, skippedIds : skipped . map ( ( s ) => s . id ) , skipped } ;
524656}
525657
526658// Execute a teardown. Dry-run (default) emits the plan (no I/O) and returns { ok, dryRun, plan }.
@@ -568,14 +700,20 @@ async function runTeardown(spec, opts = {}, deps = {}) {
568700 emit ( { phase : step . phase , status : 'skip' , label : `${ step . label } (${ skipReason || 'not found' } )` , n : myN , total } ) ;
569701 continue ;
570702 }
571- const { deletedIds, skippedIds } = await deleteStep ( sdk , handler , items ) ;
703+ const { deletedIds, skipped } = await deleteStep ( sdk , handler , items ) ;
572704 ( result . deleted [ step . kind ] = result . deleted [ step . kind ] || [ ] ) . push ( ...deletedIds ) ;
573- if ( skippedIds . length ) {
574- result . skipped . push ( `${ step . label } (${ skippedIds . length } undeletable — skipped)` ) ;
705+ // Report each skip reason in its own words. "undeletable" tells an operator there is nothing
706+ // to do; "still referenced" tells them another consumer holds it — a different situation with
707+ // a different (possibly no) follow-up.
708+ const referenced = skipped . filter ( ( s ) => s . reason === 'referenced' ) . length ;
709+ const undeletable = skipped . filter ( ( s ) => s . reason === 'undeletable' ) . length ;
710+ const parts = [ ] ;
711+ if ( undeletable ) parts . push ( `${ undeletable } undeletable` ) ;
712+ if ( referenced ) parts . push ( `${ referenced } still referenced` ) ;
713+ if ( parts . length ) {
714+ result . skipped . push ( `${ step . label } (${ parts . join ( ', ' ) } — skipped)` ) ;
575715 }
576- const summary = skippedIds . length
577- ? `${ deletedIds . length } deleted, ${ skippedIds . length } undeletable`
578- : `${ deletedIds . length } deleted` ;
716+ const summary = [ `${ deletedIds . length } deleted` , ...parts ] . join ( ', ' ) ;
579717 emit ( { phase : step . phase , status : 'ok' , label : `${ step . label } (${ summary } )` , n : myN , total } ) ;
580718 } catch ( err ) {
581719 result . ok = false ;
0 commit comments