From 4eb1632775897c4c3c8f0cf226dcecae58e51a29 Mon Sep 17 00:00:00 2001 From: akshay-viz <181884695+akshay-viz@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:50:28 -0700 Subject: [PATCH 1/5] fix(model-apps): teardown owns generative-page deletion The SDK's deleteAppCascade no longer deletes an app's generative pages. A uxagentproject is REFERENCED by an app, not owned by one -- another app's sitemap can carry the same GenPageId, and a form can embed the page through the MscrmControls.UxAgentControl PCF's RefId, a reference stored in formxml that appears in no sitemap at all. The SDK reports them instead and leaves the decision to the caller. We are that caller, and we are the one that CREATED the pages. Teardown now deletes them itself, in a new `genpage` step ordered immediately after the app. Ownership is established from the page manifest web resource -- the durable record of exactly which pages this build authored -- not from the app's sitemap, which would also list pages someone else added. The manifest is read before the web-resources phase deletes it. Safety is delegated to Dataverse rather than inferred from a scan. Saving an app that surfaces a page creates a real solution dependency, so deleting that page fails with "cannot be deleted because it is referenced by N other components", whether or not the app is published; the dependency clears only once the subarea is removed and published, or the app and its sitemap are deleted outright (which is what the step before this one just did). So the delete IS the check. Attempting it and reading the platform's answer is better than a pre-flight scan: it is authoritative rather than our model of the truth, it covers every surface the platform tracks instead of just sitemap XML, and it has no TOCTOU window. A dependency block is recorded as a SKIP for this kind (tolerateDependencyBlock) because another app legitimately owning the page is an expected outcome, not a broken teardown -- while staying a genuine failure for every other kind. Files are deleted before the project, since the parent cannot be removed while children reference it. Anything we cannot prove is ours -- an absent or unreadable manifest, a failed existence query -- deletes nothing. Tests: seven new cases covering manifest-scoped selection, delete ordering, the still-referenced skip, the dependency block remaining a failure for kinds that did not opt in, the already-gone page, and the missing manifest. Four existing plan-shape assertions updated for the new step; deleteRecord added to SKILL_SDK_SURFACE so the surface-contract test guards it against the real bundle. Full suite: 1443 pass, 0 fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 731c1111-d9b6-4fd7-b9d3-347f762ca7f5 --- .../model-apps/scripts/lib/sdk-teardown.js | 138 ++++++++++++++-- .../tests/sdk-surface-contract.test.js | 1 + .../scripts/tests/sdk-teardown.test.js | 149 +++++++++++++++++- 3 files changed, 272 insertions(+), 16 deletions(-) diff --git a/plugins/model-apps/scripts/lib/sdk-teardown.js b/plugins/model-apps/scripts/lib/sdk-teardown.js index cf72a93ba..143284353 100644 --- a/plugins/model-apps/scripts/lib/sdk-teardown.js +++ b/plugins/model-apps/scripts/lib/sdk-teardown.js @@ -8,6 +8,13 @@ // // Order (each the mirror of the build's create order — dependents before their dependencies): // 1. app — the app module (references the sitemap + dashboard/form/view/chart components) +// 1a. pages — generative pages (uxagentproject + files) this build AUTHORED, per the page +// manifest. The SDK's deleteAppCascade no longer removes them: a +// page is REFERENCED by an app, not owned by one — another app's sitemap, or a +// form's UxAgentControl `RefId` in formxml, can point at the same row — so the +// SDK reports them and the owner decides. Runs AFTER the app so the app's own +// sitemap reference is gone and the cross-app scan only sees genuine other +// consumers; a page any other app still references is SKIPPED, not deleted. // 1b. roles — persona security roles. Deleted right after the app (BEFORE the data model): a // role holding a soon-to-be-deleted table's privileges could otherwise block that // table's delete. SEC-1: only roles the SDK itself authored (marked on the role @@ -39,10 +46,10 @@ const { topoOrderEntities } = require('./_graph.js'); const { appUniqueName, commandsByEntity, defaultViewColumns, resolveExistingFormId, resolveRoleBusinessUnit, roleBuClause } = require('./sdk-build.js'); -const { manifestResourceName } = require('./page-manifest.js'); +const { manifestResourceName, parseManifestBase64 } = require('./page-manifest.js'); const { relationshipSchemaName, manyToManySchemaName, lookupColumnsFor, SDK_ROLE_MARKER, canonicalPersonaName, FORM_GUID_RE } = require('./app-spec.js'); const { selectSummaryTables } = require('./ai-candidates.js'); -const { isRestrictedSolution } = require('./system-solutions.js'); +const { isRestrictedSolution } = require('./system-solutions.js'); // OData v4 string-literal escaping lives in ./odata.js. `odataStr` is kept as a backward-compatible // alias because it is part of this module's exported (and unit-tested) surface. @@ -74,6 +81,17 @@ function isNotFound(err) { // teardown skips it instead of failing — the same best-effort spirit as isNotFound. Deliberately // NARROW: it must NOT match a dependency block ("...cannot be deleted because it is referenced by // N other components"), which is a genuine leftover the teardown must surface, not swallow. +// Dataverse refused a delete because another component still references the record: +// "The () component cannot be deleted because it is referenced by N other components." +// For MOST kinds that is a genuine leftover the teardown must surface. For a generative page it is +// the correct, expected answer — the page belongs to whoever still points at it — so only handlers +// that opt in via `tolerateDependencyBlock` treat it as a skip. +function isDependencyBlocked(err) { + if (!err) return false; + const msg = String((err && err.message) || '').toLowerCase(); + return /cannot be deleted because it is referenced by/.test(msg) || /referenced by \d+ other component/.test(msg); +} + function isUndeletable(err) { if (!err) return false; const msg = String((err && err.message) || '').toLowerCase(); @@ -120,15 +138,18 @@ const KIND_HANDLERS = { const items = await sdk.resolveArtifact('app', { uniqueName: target.uniqueName }); return (items || []).map((x) => ({ id: x.id, name: x.name, appModuleIdUnique: x.appModuleIdUnique })); }, - // deleteAppCascade fail-fast-deletes the app module, then best-effort cleans up the - // orphaned sitemap + generative-page rows (uxagentproject[file]). It returns a structured - // { success, deleted, failures } result (older vendored bundles returned void). The app - // record itself is gone once this resolves, but an individual child-cleanup step can still - // fail — which the old void contract swallowed, silently leaving orphaned rows while the - // teardown reported a clean delete. Surface any GENUINE child failure so the run reports - // ok=false with the exact leftovers. A not-found child failure means the row already - // cascaded away (not a leftover), so it is tolerated — the same best-effort spirit as the - // step-level isNotFound handling in deleteStep. + // deleteAppCascade fail-fast-deletes the app module together with its sitemap (atomically), and + // returns a structured { success, deleted, failures, retained } result (older vendored bundles + // returned void). It deliberately does NOT delete the app's generative pages — a `uxagentproject` + // is referenced by an app, not owned by one, so it reports them in `retained` and the owner + // decides. The `genpage` step that follows is that decision: it deletes the pages + // THIS build authored, per the page manifest, skipping any another app still references. + // + // The app record itself is gone once this resolves, but a cleanup step can still fail — which the + // old void contract swallowed, silently leaving orphaned rows while teardown reported a clean + // delete. Surface any GENUINE failure so the run reports ok=false with the exact leftovers. A + // not-found failure means the row already cascaded away (not a leftover), so it is tolerated — + // the same best-effort spirit as the step-level isNotFound handling in deleteStep. async del(sdk, item) { const result = await sdk.deleteAppCascade(item.id, item.appModuleIdUnique); const failures = (result && Array.isArray(result.failures) ? result.failures : []).filter( @@ -144,6 +165,79 @@ const KIND_HANDLERS = { } }, }, + // Generative pages the build authored. The SDK's `deleteAppCascade` deliberately does NOT delete + // these: a `uxagentproject` is REFERENCED by an app, not owned by one, so the SDK + // reports them in `retained` and leaves the decision to the caller. WE are the caller that CREATED + // them, and the page manifest is the durable record of exactly which pages this build authored — + // so teardown deletes those, and only those. + // + // Safety is delegated to DATAVERSE, not inferred from a scan. Verified against a live environment: + // saving an app that surfaces a page creates a real solution dependency, and DELETE on that page + // returns 400 "component cannot be deleted because it is referenced by N other components" — + // whether or not the app is published. The dependency clears only when the referencing sitemap is + // removed AND published, or when the app+sitemap are deleted outright (which is what the step + // before this one just did). + // + // So the delete IS the check. Attempting it and reading the platform's answer is strictly better + // than a pre-flight scan: it is authoritative (the platform's own dependency graph, not our model + // of it), it covers every surface the platform tracks rather than just sitemap XML, and it has no + // TOCTOU window — a pre-check can go stale between the check and the delete, this cannot. + genpage: { + // A page another app still references is a SKIP, not a failure — see isDependencyBlocked. + tolerateDependencyBlock: true, + async resolve(sdk, target) { + if (typeof sdk.queryRecords !== 'function') return []; + // The manifest lives in a web resource this same teardown deletes later (web-resources phase), + // so it is still readable here. + let manifest = null; + try { + const rows = await sdk.queryRecords('webresource', { + select: ['content'], + filter: `name eq '${odataStr(target.manifestName)}'`, + top: 1, + }); + if (rows && rows[0] && rows[0].content) manifest = parseManifestBase64(rows[0].content); + } catch { + // No manifest readable → nothing provably ours → delete nothing. Leaving a row behind is + // recoverable; deleting a page we cannot prove we authored is not. + return []; + } + const authored = []; + for (const p of (manifest && manifest.pages) || []) { + if (p && typeof p.pageId === 'string' && FORM_GUID_RE.test(p.pageId)) { + authored.push({ id: p.pageId, name: p.name || p.key || p.pageId }); + } + } + if (!authored.length) return []; + + // Only pages that still exist (a re-run, or a maker deleting one by hand, is not a failure). + try { + const filter = authored.map((a) => `uxagentprojectid eq ${String(a.id).toLowerCase()}`).join(' or '); + const rows = await sdk.queryRecords('uxagentproject', { select: ['uxagentprojectid'], filter }); + const live = new Set((rows || []).map((r) => String(r.uxagentprojectid).toLowerCase())); + return authored.filter((a) => live.has(String(a.id).toLowerCase())); + } catch { + return []; + } + }, + // Files first, then the project — the parent cannot be removed while children reference it. + // A page still in use fails here with a dependency error, which `deleteStep` records as a SKIP + // (see isUndeletable) rather than a teardown failure: another app legitimately owning the page + // is an expected outcome, not a broken teardown. + async del(sdk, item) { + const files = await sdk.queryRecords('uxagentprojectfile', { + select: ['uxagentprojectfileid'], + filter: `_uxagentprojectid_value eq ${item.id}`, + paginate: true, + }); + for (const f of files || []) { + if (f && f.uxagentprojectfileid) { + await sdk.deleteRecord('uxagentprojectfile', f.uxagentprojectfileid); + } + } + await sdk.deleteRecord('uxagentproject', item.id); + }, + }, dashboard: { async resolve(sdk, target) { const items = await sdk.resolveArtifact('dashboard', { name: target.name }); @@ -358,6 +452,19 @@ function planTeardown(spec) { const steps = []; if (spec.app && spec.solution) { steps.push({ kind: 'app', phase: 'app', label: `app module "${spec.app.name}"`, target: { uniqueName: appUniqueName(spec) } }); + // Generative pages, AFTER the app. The SDK no longer deletes them — a page is + // referenced by an app, not owned by one, so the SDK reports them and the owner decides. We are + // the owner: the page manifest records exactly which pages this build authored. Ordered after the + // app so the app's own sitemap reference is already gone and the cross-app scan only ever sees a + // GENUINE other consumer. Emitted for every app-bearing spec (not gated on spec.pages) so a spec + // that dropped its pages still cleans up what it previously created; resolve is a no-op when the + // manifest is absent or lists nothing. + steps.push({ + kind: 'genpage', + phase: 'pages', + label: 'generative pages authored by this app', + target: { manifestName: manifestResourceName(appUniqueName(spec)) }, + }); } // Persona security roles — deleted right after the app, before the data model (a role holding a // table's privileges could block that table's delete). The role handler is SEC-1 safe (marker-gated) @@ -511,6 +618,15 @@ async function deleteStep(sdk, handler, items) { deletedIds.push(item.id); continue; } + if (handler.tolerateDependencyBlock && isDependencyBlocked(err)) { + // The platform refused because something else still references this record. For a + // generative page that is the CORRECT outcome, not a leftover: the page belongs to whoever + // still points at it, and Dataverse is the authority on that (live-measured — saving an app + // that surfaces a page creates the dependency, published or not). Record it as skipped so + // the run stays auditable without reporting a false failure. + skippedIds.push(item.id); + continue; + } if (isUndeletable(err)) { // A system/managed artifact (e.g. an auto-generated "Active " view that shares // the spec view's name) — not ours to remove. Record it as skipped without failing. diff --git a/plugins/model-apps/scripts/tests/sdk-surface-contract.test.js b/plugins/model-apps/scripts/tests/sdk-surface-contract.test.js index 16dc6abc7..088f29109 100644 --- a/plugins/model-apps/scripts/tests/sdk-surface-contract.test.js +++ b/plugins/model-apps/scripts/tests/sdk-surface-contract.test.js @@ -49,6 +49,7 @@ const SKILL_SDK_SURFACE = [ 'createWebResource', 'deleteAppCascade', 'deleteGlobalOptionSet', + 'deleteRecord', 'deleteRelationship', 'deleteRemoteArtifact', 'deleteSecurityRole', diff --git a/plugins/model-apps/scripts/tests/sdk-teardown.test.js b/plugins/model-apps/scripts/tests/sdk-teardown.test.js index 9924b6fa8..75fb0d19b 100644 --- a/plugins/model-apps/scripts/tests/sdk-teardown.test.js +++ b/plugins/model-apps/scripts/tests/sdk-teardown.test.js @@ -225,7 +225,7 @@ test('plan is ordered app -> dashboards -> commands -> forms -> charts -> views const kinds = steps.map((s) => s.kind); // Gap 6: resetDefaultViews steps (drop parent lookups from un-deletable default views) precede the // relationships. fullSpec's ticket + comment are 1:N children, so both get a reset step. - assert.deepStrictEqual(kinds, ['app', 'dashboard', 'commands', 'form', 'form', 'form', 'chart', 'chart', 'view', 'view', 'view', 'resetDefaultViews', 'resetDefaultViews', 'relationship', 'relationship', 'table', 'table', 'table', 'webResource', 'webResource', 'webResource', 'solution']); + assert.deepStrictEqual(kinds, ['app', 'genpage', 'dashboard', 'commands', 'form', 'form', 'form', 'chart', 'chart', 'view', 'view', 'view', 'resetDefaultViews', 'resetDefaultViews', 'relationship', 'relationship', 'table', 'table', 'table', 'webResource', 'webResource', 'webResource', 'solution']); }); // Regression (found by live teardown): a table's icon web resource is referenced by the table, so @@ -587,10 +587,10 @@ test('dry-run emits the whole plan as skips and never calls SDK', async () => { const throwingSdk = { queryRecords: () => { throw new Error('dry-run must not call SDK'); } }; const r = await runTeardown(fullSpec(), { apply: false }, { sdk: throwingSdk, emit: (e) => events.push(e) }); assert.strictEqual(r.dryRun, true); - assert.strictEqual(r.plan.length, 22); // +1 generated app-icon WR, +1 page-manifest WR, +2 resetDefaultViews (ticket, comment) + assert.strictEqual(r.plan.length, 23); // +1 genpage step, +1 generated app-icon WR, +1 page-manifest WR, +2 resetDefaultViews (ticket, comment) const terminal = events.filter((e) => e.status !== 'start'); assert.ok(terminal.every((e) => e.status === 'skip')); - assert.strictEqual(terminal.length, 22); + assert.strictEqual(terminal.length, 23); }); test('apply without an sdk throws', async () => { @@ -660,7 +660,7 @@ test('not-found artifacts are skipped, not errors, and issue no delete', async ( // Tables will attempt deleteTable (synthetic item) but get not-found immediately, counted as deleted // Relationships will attempt deleteRelationship but get not-found, counted as deleted (tolerateNotFound) // Other artifacts (app, dashboard, commands, forms, charts, views, webResource, solution) skip when resolve returns [] - assert.strictEqual(r.skipped.length, 15); // app, dashboard, commands, 3 forms, 2 charts, 3 views, webResource, generated app-icon WR, page-manifest WR, solution + assert.strictEqual(r.skipped.length, 16); // app, genpage, dashboard, commands, 3 forms, 2 charts, 3 views, webResource, generated app-icon WR, page-manifest WR, solution assert.strictEqual((r.deleted.table || []).length, 3); // tables counted as deleted (tolerateNotFound) assert.strictEqual((r.deleted.relationship || []).length, 2); // relationships counted as deleted (tolerateNotFound) // Only table/relationship deletes were attempted (synthetic items); other kinds skipped before delete @@ -896,8 +896,10 @@ test('planTeardown inserts persona role steps right after the app, before the da ] }); const kinds = planTeardown(spec).map((s) => s.kind); assert.strictEqual(kinds[0], 'app'); - assert.strictEqual(kinds[1], 'role'); + // Generative pages are torn down immediately after the app (the SDK no longer cascades them). + assert.strictEqual(kinds[1], 'genpage'); assert.strictEqual(kinds[2], 'role'); + assert.strictEqual(kinds[3], 'role'); // Roles are torn down before any relationship/table delete (a role holding a table's privileges // could otherwise block that table's delete). assert.ok(kinds.lastIndexOf('role') < kinds.indexOf('relationship'), 'roles before relationships/tables'); @@ -984,3 +986,140 @@ test('planTeardown trims the persona name in the role step (matches the SDK-crea assert.strictEqual(roleStep.target.name, 'Agent'); }); + +// --------------------------------------------------------------------------------------------- +// Generative pages: the SDK's deleteAppCascade no longer removes them, so teardown +// owns that decision for the pages IT authored. These pin who deletes what, and when it backs off. +// --------------------------------------------------------------------------------------------- + +// A minimal SDK stand-in for the genpage handler: a page manifest in a web resource, the live +// uxagentproject/file rows, and the appmodule sitemaps the cross-app scan reads. +// A minimal SDK stand-in for the genpage handler: a page manifest in a web resource, the live +// uxagentproject/file rows, and the OTHER apps + sitemaps the cross-app scan walks. +// `otherApps` is [{ unique, xml }] — the scan enumerates appmodules, then reads each one's sitemap +// via appmodulecomponent (componenttype 62) -> sitemap row. A real env ALWAYS has system apps, and +// fetchAppsForPages fail-closes on an empty enumeration, so every case supplies at least one. +function genpageSdk({ manifestPages = [], livePages = [], files = {}, otherApps = [{ unique: 'system_app', xml: '' }], sitemapThrows = false } = {}) { + const deleted = []; + const manifestJson = JSON.stringify({ schemaVersion: 1, pages: manifestPages }); + const byUnique = new Map(otherApps.map((a, i) => [a.unique, { i, xml: a.xml }])); + const sdk = { + async queryRecords(entity, opts = {}) { + const filter = String(opts.filter || ''); + if (entity === 'webresource') { + return [{ content: Buffer.from(manifestJson, 'utf8').toString('base64') }]; + } + if (entity === 'uxagentproject') { + return livePages.filter((id) => filter.toLowerCase().includes(id.toLowerCase())).map((id) => ({ uxagentprojectid: id })); + } + if (entity === 'uxagentprojectfile') { + const owner = (/_uxagentprojectid_value eq ([0-9a-f-]+)/i.exec(filter) || [])[1] || ''; + return (files[owner.toLowerCase()] || []).map((id) => ({ uxagentprojectfileid: id })); + } + if (entity === 'appmodule') { + const m = /uniquename eq '([^']+)'/.exec(filter); + if (m) { + const hit = byUnique.get(m[1]); + return hit ? [{ appmoduleid: `app-${hit.i}`, appmoduleidunique: `uniq-${hit.i}` }] : []; + } + // The env-wide enumeration. + return otherApps.map((a, i) => ({ appmoduleid: `app-${i}`, appmoduleidunique: `uniq-${i}`, uniquename: a.unique })); + } + if (entity === 'appmodulecomponent') { + const m = /_appmoduleidunique_value eq (\S+)/.exec(filter); + return m ? [{ objectid: `sm-${m[1]}`, componenttype: 62 }] : []; + } + if (entity === 'sitemap') { + if (sitemapThrows) throw new Error('sitemap unreadable'); + const m = /sitemapid eq ([\w-]+)/.exec(filter) || /sm-uniq-(\d+)/.exec(filter); + const idx = m ? Number(String(m[1]).replace(/\D/g, '')) : 0; + const app = otherApps[idx]; + return app ? [{ sitemapxml: app.xml }] : []; + } + return []; + }, + async deleteRecord(entity, id) { + deleted.push(`${entity}:${id}`); + }, + }; + return { sdk, deleted }; +} +const PAGE_1 = '11111111-1111-4111-8111-111111111111'; +const PAGE_2 = '22222222-2222-4222-8222-222222222222'; + +test('genpage resolve returns only pages the manifest says WE authored', async () => { + const { sdk } = genpageSdk({ + manifestPages: [{ key: 'overview', name: 'Overview', pageId: PAGE_1 }], + livePages: [PAGE_1, PAGE_2], // PAGE_2 exists but is not ours + }); + const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' }); + assert.deepStrictEqual(items.map((i) => i.id), [PAGE_1]); +}); + +test('genpage del removes the file rows before the project', async () => { + const { sdk, deleted } = genpageSdk({ files: { [PAGE_1.toLowerCase()]: ['f1', 'f2'] } }); + await KIND_HANDLERS.genpage.del(sdk, { id: PAGE_1, name: 'Overview' }); + assert.deepStrictEqual(deleted, [ + 'uxagentprojectfile:f1', + 'uxagentprojectfile:f2', + `uxagentproject:${PAGE_1}`, + ]); +}); + +// Dataverse is the authority on whether a page is still in use: saving an app that surfaces a page +// creates a real dependency, and the DELETE then fails with "cannot be deleted because it is +// referenced by N other components" (live-measured, published or not). The delete IS the check — +// there is no pre-flight scan to go stale. +test('genpage records a still-referenced page as SKIPPED, not a failure', async () => { + const err = new Error('The uxagentproject(11111111) component cannot be deleted because it is referenced by 1 other components.'); + const sdk = { + async queryRecords() { return []; }, + async deleteRecord() { throw err; }, + }; + const r = await deleteStep(sdk, KIND_HANDLERS.genpage, [{ id: PAGE_1, name: 'Overview' }]); + assert.deepStrictEqual(r.deletedIds, []); + assert.deepStrictEqual(r.skippedIds, [PAGE_1]); +}); + +test('a dependency block is still a FAILURE for kinds that did not opt in', async () => { + const err = new Error('The savedquery(x) component cannot be deleted because it is referenced by 1 other components.'); + const handler = { del: async () => { throw err; } }; + await assert.rejects(() => deleteStep({}, handler, [{ id: 'v1' }]), /referenced by/); +}); + +test('genpage deletes a page no other app references', async () => { + const { sdk } = genpageSdk({ + manifestPages: [{ key: 'overview', name: 'Overview', pageId: PAGE_1 }], + livePages: [PAGE_1], + otherApps: [{ unique: 'other_app', xml: `` }], + }); + const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' }); + assert.deepStrictEqual(items.map((i) => i.id), [PAGE_1]); +}); + +test('genpage deletes nothing when the live-existence query fails (cannot prove what is ours)', async () => { + const sdk = { + async queryRecords(entity) { + if (entity === 'webresource') return [{ content: Buffer.from(JSON.stringify({ schemaVersion: 1, pages: [{ key: 'overview', name: 'Overview', pageId: PAGE_1 }] }), 'utf8').toString('base64') }]; + throw new Error('uxagentproject query failed'); + }, + async deleteRecord() {}, + }; + const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' }); + assert.deepStrictEqual(items, []); +}); + +test('genpage skips a page the manifest claims but that no longer exists', async () => { + const { sdk } = genpageSdk({ + manifestPages: [{ key: 'gone', name: 'Gone', pageId: PAGE_1 }], + livePages: [], // already deleted by hand + }); + const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' }); + assert.deepStrictEqual(items, []); +}); + +test('genpage deletes nothing when the manifest is absent or unreadable', async () => { + const sdk = { async queryRecords() { throw new Error('no manifest'); }, async deleteRecord() {} }; + const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' }); + assert.deepStrictEqual(items, []); +}); From bb1a20513055bdb103e940640ec7c210d07be3e1 Mon Sep 17 00:00:00 2001 From: akshay-viz <181884695+akshay-viz@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:59:58 -0700 Subject: [PATCH 2/5] 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 --- .../model-apps/scripts/lib/sdk-teardown.js | 37 ++++---- .../scripts/tests/sdk-teardown.test.js | 91 ++++++++++--------- 2 files changed, 67 insertions(+), 61 deletions(-) diff --git a/plugins/model-apps/scripts/lib/sdk-teardown.js b/plugins/model-apps/scripts/lib/sdk-teardown.js index 143284353..182f9d66c 100644 --- a/plugins/model-apps/scripts/lib/sdk-teardown.js +++ b/plugins/model-apps/scripts/lib/sdk-teardown.js @@ -13,8 +13,8 @@ // page is REFERENCED by an app, not owned by one — another app's sitemap, or a // form's UxAgentControl `RefId` in formxml, can point at the same row — so the // SDK reports them and the owner decides. Runs AFTER the app so the app's own -// sitemap reference is gone and the cross-app scan only sees genuine other -// consumers; a page any other app still references is SKIPPED, not deleted. +// sitemap reference is already gone and the only dependency the platform can +// still report is a GENUINE other consumer; such a page is SKIPPED, not deleted. // 1b. roles — persona security roles. Deleted right after the app (BEFORE the data model): a // role holding a soon-to-be-deleted table's privileges could otherwise block that // table's delete. SEC-1: only roles the SDK itself authored (marked on the role @@ -220,21 +220,18 @@ const KIND_HANDLERS = { return []; } }, - // Files first, then the project — the parent cannot be removed while children reference it. - // A page still in use fails here with a dependency error, which `deleteStep` records as a SKIP - // (see isUndeletable) rather than a teardown failure: another app legitimately owning the page - // is an expected outcome, not a broken teardown. + // Delete ONLY the project row. Its `uxagentprojectfile` children go with it: the + // uxagentproject_uxagentprojectfile_uxagentprojectid relationship is CascadeConfiguration + // Delete=Cascade, so the platform removes them for us. + // + // Deleting the files ourselves first would be actively DESTRUCTIVE. Dataverse tracks a + // dependency on the PROJECT row (component type 10372) but NOT on its files (10373): + // measured on pages that an app sitemap references, the project reports 1 dependent and its + // DELETE is refused, while every one of its files reports ZERO dependents and would delete + // cleanly. So a files-first order would strip the content out of a page the platform is about + // to refuse to delete, leaving the app that still references it pointing at an empty shell — + // exactly the data loss this step exists to avoid. One delete, and the platform decides. async del(sdk, item) { - const files = await sdk.queryRecords('uxagentprojectfile', { - select: ['uxagentprojectfileid'], - filter: `_uxagentprojectid_value eq ${item.id}`, - paginate: true, - }); - for (const f of files || []) { - if (f && f.uxagentprojectfileid) { - await sdk.deleteRecord('uxagentprojectfile', f.uxagentprojectfileid); - } - } await sdk.deleteRecord('uxagentproject', item.id); }, }, @@ -455,10 +452,10 @@ function planTeardown(spec) { // Generative pages, AFTER the app. The SDK no longer deletes them — a page is // referenced by an app, not owned by one, so the SDK reports them and the owner decides. We are // the owner: the page manifest records exactly which pages this build authored. Ordered after the - // app so the app's own sitemap reference is already gone and the cross-app scan only ever sees a - // GENUINE other consumer. Emitted for every app-bearing spec (not gated on spec.pages) so a spec - // that dropped its pages still cleans up what it previously created; resolve is a no-op when the - // manifest is absent or lists nothing. + // app so the app's own sitemap reference is already gone and any dependency the platform still + // reports belongs to a GENUINE other consumer. Emitted for every app-bearing spec (not gated on + // spec.pages) so a spec that dropped its pages still cleans up what it previously created; + // resolve is a no-op when the manifest is absent or lists nothing. steps.push({ kind: 'genpage', phase: 'pages', diff --git a/plugins/model-apps/scripts/tests/sdk-teardown.test.js b/plugins/model-apps/scripts/tests/sdk-teardown.test.js index 75fb0d19b..2d44e58b6 100644 --- a/plugins/model-apps/scripts/tests/sdk-teardown.test.js +++ b/plugins/model-apps/scripts/tests/sdk-teardown.test.js @@ -447,7 +447,7 @@ test('app teardown resolves via resolveArtifact and delegates the full cascade t await h.del(sdk, items[0]); assert.strictEqual(cascadeCalls.length, 1, 'deleteAppCascade called once'); assert.strictEqual(cascadeCalls[0].appModuleId, 'app-1', 'app module id passed'); - assert.strictEqual(cascadeCalls[0].appModuleIdUnique, 'u-1', 'unique id passed (deleteAppCascade handles sitemap + genpage internally)'); + assert.strictEqual(cascadeCalls[0].appModuleIdUnique, 'u-1', 'unique id passed (deleteAppCascade removes the sitemap; generative pages are a separate step)'); }); 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 // owns that decision for the pages IT authored. These pin who deletes what, and when it backs off. // --------------------------------------------------------------------------------------------- -// A minimal SDK stand-in for the genpage handler: a page manifest in a web resource, the live -// uxagentproject/file rows, and the appmodule sitemaps the cross-app scan reads. -// A minimal SDK stand-in for the genpage handler: a page manifest in a web resource, the live -// uxagentproject/file rows, and the OTHER apps + sitemaps the cross-app scan walks. -// `otherApps` is [{ unique, xml }] — the scan enumerates appmodules, then reads each one's sitemap -// via appmodulecomponent (componenttype 62) -> sitemap row. A real env ALWAYS has system apps, and -// fetchAppsForPages fail-closes on an empty enumeration, so every case supplies at least one. -function genpageSdk({ manifestPages = [], livePages = [], files = {}, otherApps = [{ unique: 'system_app', xml: '' }], sitemapThrows = false } = {}) { +// A minimal SDK stand-in for the genpage handler: the page manifest (in a web resource) that says +// which pages this build authored, and the live `uxagentproject` rows used to skip ones already +// gone. The `uxagentprojectfile` branch exists ONLY so tests can prove the handler never touches +// it — deleting the project cascades to its files, and deleting them ourselves would gut a page +// the platform is about to refuse to delete. +function genpageSdk({ manifestPages = [], livePages = [], files = {} } = {}) { const deleted = []; + const queried = []; const manifestJson = JSON.stringify({ schemaVersion: 1, pages: manifestPages }); - const byUnique = new Map(otherApps.map((a, i) => [a.unique, { i, xml: a.xml }])); const sdk = { async queryRecords(entity, opts = {}) { + queried.push(entity); const filter = String(opts.filter || ''); if (entity === 'webresource') { return [{ content: Buffer.from(manifestJson, 'utf8').toString('base64') }]; @@ -1016,33 +1015,13 @@ function genpageSdk({ manifestPages = [], livePages = [], files = {}, otherApps const owner = (/_uxagentprojectid_value eq ([0-9a-f-]+)/i.exec(filter) || [])[1] || ''; return (files[owner.toLowerCase()] || []).map((id) => ({ uxagentprojectfileid: id })); } - if (entity === 'appmodule') { - const m = /uniquename eq '([^']+)'/.exec(filter); - if (m) { - const hit = byUnique.get(m[1]); - return hit ? [{ appmoduleid: `app-${hit.i}`, appmoduleidunique: `uniq-${hit.i}` }] : []; - } - // The env-wide enumeration. - return otherApps.map((a, i) => ({ appmoduleid: `app-${i}`, appmoduleidunique: `uniq-${i}`, uniquename: a.unique })); - } - if (entity === 'appmodulecomponent') { - const m = /_appmoduleidunique_value eq (\S+)/.exec(filter); - return m ? [{ objectid: `sm-${m[1]}`, componenttype: 62 }] : []; - } - if (entity === 'sitemap') { - if (sitemapThrows) throw new Error('sitemap unreadable'); - const m = /sitemapid eq ([\w-]+)/.exec(filter) || /sm-uniq-(\d+)/.exec(filter); - const idx = m ? Number(String(m[1]).replace(/\D/g, '')) : 0; - const app = otherApps[idx]; - return app ? [{ sitemapxml: app.xml }] : []; - } return []; }, async deleteRecord(entity, id) { deleted.push(`${entity}:${id}`); }, }; - return { sdk, deleted }; + return { sdk, deleted, queried }; } const PAGE_1 = '11111111-1111-4111-8111-111111111111'; const PAGE_2 = '22222222-2222-4222-8222-222222222222'; @@ -1056,14 +1035,14 @@ test('genpage resolve returns only pages the manifest says WE authored', async ( assert.deepStrictEqual(items.map((i) => i.id), [PAGE_1]); }); -test('genpage del removes the file rows before the project', async () => { - const { sdk, deleted } = genpageSdk({ files: { [PAGE_1.toLowerCase()]: ['f1', 'f2'] } }); +// Deleting the project cascades to its files (the uxagentproject -> uxagentprojectfile +// relationship is CascadeConfiguration Delete=Cascade), so ONE delete is both necessary and +// sufficient. Deleting files ourselves first would be destructive — see the skip test below. +test('genpage del deletes ONLY the project row and never touches its files', async () => { + const { sdk, deleted, queried } = genpageSdk({ files: { [PAGE_1.toLowerCase()]: ['f1', 'f2'] } }); await KIND_HANDLERS.genpage.del(sdk, { id: PAGE_1, name: 'Overview' }); - assert.deepStrictEqual(deleted, [ - 'uxagentprojectfile:f1', - 'uxagentprojectfile:f2', - `uxagentproject:${PAGE_1}`, - ]); + assert.deepStrictEqual(deleted, [`uxagentproject:${PAGE_1}`]); + assert.ok(!queried.includes('uxagentprojectfile'), 'must not enumerate the page files'); }); // 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 await assert.rejects(() => deleteStep({}, handler, [{ id: 'v1' }]), /referenced by/); }); -test('genpage deletes a page no other app references', async () => { +// THE regression this ordering exists for. Dataverse tracks a dependency on the page ROW +// (component type 10372) but NOT on its files (10373): measured on pages an app sitemap +// references, the project reports 1 dependent and its DELETE is refused, while every one of its +// files reports ZERO dependents and would delete cleanly. So if the handler deleted files first, +// a skipped page would be left as an empty shell for the app that still references it. +test('a still-referenced page is SKIPPED with its files left completely intact', async () => { + const err = new Error('The uxagentproject(11111111) component cannot be deleted because it is referenced by 1 other components.'); + const deleted = []; + const sdk = { + // The page HAS files — a handler that enumerated and deleted them first would destroy the + // content of a page the platform then refuses to delete. + async queryRecords(entity) { + return entity === 'uxagentprojectfile' + ? [{ uxagentprojectfileid: 'f1' }, { uxagentprojectfileid: 'f2' }] + : []; + }, + async deleteRecord(entity, id) { + deleted.push(`${entity}:${id}`); + if (entity === 'uxagentproject') throw err; + }, + }; + const r = await deleteStep(sdk, KIND_HANDLERS.genpage, [{ id: PAGE_1, name: 'Overview' }]); + assert.deepStrictEqual(r.skippedIds, [PAGE_1]); + assert.deepStrictEqual(r.deletedIds, []); + assert.deepStrictEqual( + deleted, + [`uxagentproject:${PAGE_1}`], + 'the page delete must be the ONLY write attempted — its files must survive the skip' + ); +}); + +test('genpage resolve ignores live pages the manifest does not claim', async () => { const { sdk } = genpageSdk({ manifestPages: [{ key: 'overview', name: 'Overview', pageId: PAGE_1 }], - livePages: [PAGE_1], - otherApps: [{ unique: 'other_app', xml: `` }], + livePages: [PAGE_1, PAGE_2], }); const items = await KIND_HANDLERS.genpage.resolve(sdk, { manifestName: 'new_app_pagemanifest' }); assert.deepStrictEqual(items.map((i) => i.id), [PAGE_1]); From ca1b76d380551b66a0efe8810d755078fc47a7a4 Mon Sep 17 00:00:00 2001 From: akshay-viz <181884695+akshay-viz@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:22:57 -0700 Subject: [PATCH 3/5] fix(model-apps): report a referenced page as referenced, not undeletable Review follow-up. deleteStep collapsed two opposite outcomes into one skippedIds list, and the reporting called both "undeletable": - undeletable a system/managed artifact that can NEVER be removed. Nothing an operator can act on. - referenced the platform refused because something else still points at it. Perfectly deletable once that consumer releases it, and for a generative page it is the expected result. A teardown that skipped a shared page therefore printed "1 undeletable", sending whoever read it hunting a platform problem that did not exist. deleteStep now returns skipped: [{ id, reason }] alongside the existing skippedIds union, so count-only callers are unaffected, and the step summary names each reason separately: before generative pages authored by this app (0 deleted, 2 undeletable) after generative pages authored by this app (0 deleted, 2 still referenced) Adds a test per reason so the two cannot silently merge again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 731c1111-d9b6-4fd7-b9d3-347f762ca7f5 --- .../model-apps/scripts/lib/sdk-teardown.js | 47 ++++++++++++------- .../scripts/tests/sdk-teardown.test.js | 23 ++++++++- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/plugins/model-apps/scripts/lib/sdk-teardown.js b/plugins/model-apps/scripts/lib/sdk-teardown.js index 182f9d66c..67f229d2a 100644 --- a/plugins/model-apps/scripts/lib/sdk-teardown.js +++ b/plugins/model-apps/scripts/lib/sdk-teardown.js @@ -593,13 +593,21 @@ function planTeardown(spec) { } // Delete the resolved artifacts for one plan step via SDK methods. Returns `{ deletedIds, -// skippedIds }` — `skippedIds` are artifacts that exist but cannot be removed (system/managed), -// surfaced so a destructive run is auditable rather than silently reporting "(0 deleted)". A -// not-found error counts as already-gone. Throws only on a genuine failure (non-not-found, -// non-undeletable). +// skippedIds, skipped }` — artifacts that exist but were not removed, surfaced so a destructive run +// is auditable rather than silently reporting "(0 deleted)". `skipped` carries a REASON per id +// because the two cases mean opposite things to an operator: +// +// - `undeletable` — a system/managed artifact that can never be removed. Nothing to act on. +// - `referenced` — the platform refused because something else still points at it. The record +// is perfectly deletable once that consumer releases it; for a generative page +// this is the CORRECT, expected outcome, not a defect. +// +// Reporting both as "undeletable" would send an operator hunting a platform problem that isn't +// there. `skippedIds` is retained as the union of both for callers that only need the count. +// A not-found error counts as already-gone. Throws only on a genuine failure. async function deleteStep(sdk, handler, items) { const deletedIds = []; - const skippedIds = []; + const skipped = []; for (const item of items) { try { await handler.del(sdk, item); @@ -619,21 +627,22 @@ async function deleteStep(sdk, handler, items) { // The platform refused because something else still references this record. For a // generative page that is the CORRECT outcome, not a leftover: the page belongs to whoever // still points at it, and Dataverse is the authority on that (live-measured — saving an app - // that surfaces a page creates the dependency, published or not). Record it as skipped so - // the run stays auditable without reporting a false failure. - skippedIds.push(item.id); + // that surfaces a page creates the dependency, published or not). Recorded as `referenced` + // rather than `undeletable` so the run stays auditable AND the operator is told the truth: + // nothing is broken, someone else is still using it. + skipped.push({ id: item.id, reason: 'referenced' }); continue; } if (isUndeletable(err)) { // A system/managed artifact (e.g. an auto-generated "Active " view that shares // the spec view's name) — not ours to remove. Record it as skipped without failing. - skippedIds.push(item.id); + skipped.push({ id: item.id, reason: 'undeletable' }); continue; } throw err; } } - return { deletedIds, skippedIds }; + return { deletedIds, skippedIds: skipped.map((s) => s.id), skipped }; } // Execute a teardown. Dry-run (default) emits the plan (no I/O) and returns { ok, dryRun, plan }. @@ -681,14 +690,20 @@ async function runTeardown(spec, opts = {}, deps = {}) { emit({ phase: step.phase, status: 'skip', label: `${step.label} (${skipReason || 'not found'})`, n: myN, total }); continue; } - const { deletedIds, skippedIds } = await deleteStep(sdk, handler, items); + const { deletedIds, skipped } = await deleteStep(sdk, handler, items); (result.deleted[step.kind] = result.deleted[step.kind] || []).push(...deletedIds); - if (skippedIds.length) { - result.skipped.push(`${step.label} (${skippedIds.length} undeletable — skipped)`); + // Report each skip reason in its own words. "undeletable" tells an operator there is nothing + // to do; "still referenced" tells them another consumer holds it — a different situation with + // a different (possibly no) follow-up. + const referenced = skipped.filter((s) => s.reason === 'referenced').length; + const undeletable = skipped.filter((s) => s.reason === 'undeletable').length; + const parts = []; + if (undeletable) parts.push(`${undeletable} undeletable`); + if (referenced) parts.push(`${referenced} still referenced`); + if (parts.length) { + result.skipped.push(`${step.label} (${parts.join(', ')} — skipped)`); } - const summary = skippedIds.length - ? `${deletedIds.length} deleted, ${skippedIds.length} undeletable` - : `${deletedIds.length} deleted`; + const summary = [`${deletedIds.length} deleted`, ...parts].join(', '); emit({ phase: step.phase, status: 'ok', label: `${step.label} (${summary})`, n: myN, total }); } catch (err) { result.ok = false; diff --git a/plugins/model-apps/scripts/tests/sdk-teardown.test.js b/plugins/model-apps/scripts/tests/sdk-teardown.test.js index 2d44e58b6..ab841a987 100644 --- a/plugins/model-apps/scripts/tests/sdk-teardown.test.js +++ b/plugins/model-apps/scripts/tests/sdk-teardown.test.js @@ -1048,7 +1048,8 @@ test('genpage del deletes ONLY the project row and never touches its files', asy // Dataverse is the authority on whether a page is still in use: saving an app that surfaces a page // creates a real dependency, and the DELETE then fails with "cannot be deleted because it is // referenced by N other components" (live-measured, published or not). The delete IS the check — -// there is no pre-flight scan to go stale. +// there is no pre-flight scan to go stale. (The stronger form of this — asserting the files survive +// — is below; this one pins the deleted/skipped bookkeeping.) test('genpage records a still-referenced page as SKIPPED, not a failure', async () => { const err = new Error('The uxagentproject(11111111) component cannot be deleted because it is referenced by 1 other components.'); const sdk = { @@ -1060,6 +1061,26 @@ test('genpage records a still-referenced page as SKIPPED, not a failure', async assert.deepStrictEqual(r.skippedIds, [PAGE_1]); }); +// A referenced page is a normal outcome, so the run must say so in the operator's words. Reporting +// it as "undeletable" — the wording reserved for system/managed artifacts — would send someone +// hunting a platform problem that does not exist. +test('a dependency block reports as "still referenced", never "undeletable"', async () => { + const err = new Error('The uxagentproject(11111111) component cannot be deleted because it is referenced by 1 other components.'); + const sdk = { + async queryRecords() { return []; }, + async deleteRecord() { throw err; }, + }; + const r = await deleteStep(sdk, KIND_HANDLERS.genpage, [{ id: PAGE_1, name: 'Overview' }]); + assert.deepStrictEqual(r.skipped, [{ id: PAGE_1, reason: 'referenced' }]); + assert.deepStrictEqual(r.skippedIds, [PAGE_1], 'union list still populated for count-only callers'); +}); + +test('a system/managed artifact still reports as "undeletable", not "referenced"', async () => { + const sdk = { deleteRemoteArtifact: async () => { const e = new Error('System-defined views cannot be deleted. SavedQuery Active X cannot be deleted.'); e.statusCode = 400; throw e; } }; + const r = await deleteStep(sdk, KIND_HANDLERS.view, [{ id: 'v1', name: 'Active X' }]); + assert.deepStrictEqual(r.skipped, [{ id: 'v1', reason: 'undeletable' }]); +}); + test('a dependency block is still a FAILURE for kinds that did not opt in', async () => { const err = new Error('The savedquery(x) component cannot be deleted because it is referenced by 1 other components.'); const handler = { del: async () => { throw err; } }; From 5666412bcbaabcaeb3a64e8a1d33c2c2b3b900ad Mon Sep 17 00:00:00 2001 From: akshay-viz <181884695+akshay-viz@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:47:19 -0700 Subject: [PATCH 4/5] docs(model-apps): record that form-embedded pages are not dependency-tracked The teardown comment claimed attempting the delete "covers every surface the platform tracks". True, but it invited the wrong conclusion, because that set excludes forms. Measured: a form was built with a page in the MscrmControls.UxAgentControl PCF's RefId across all three form factors, saved AND published, then read back to confirm the control persisted rather than being silently stripped. The page still reported ZERO dependents and DELETE returned 204. The form's own RetrieveRequiredComponents names the PCF (component type 66) and never the page, because RefId is an opaque static SingleLine.Text value the platform cannot know is a reference. So platform arbitration is authoritative for sitemaps and blind to forms. The gap is not closable by asking the platform, and a cross-app sitemap scan would not have closed it either - the reference is not in a sitemap. No behaviour change. This step only deletes pages this build authored, while tearing down the app that owns them, so it never deletes on another owner's behalf. Recording the gap so nobody reads the previous wording as a stronger guarantee than it is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 731c1111-d9b6-4fd7-b9d3-347f762ca7f5 --- plugins/model-apps/scripts/lib/sdk-teardown.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/model-apps/scripts/lib/sdk-teardown.js b/plugins/model-apps/scripts/lib/sdk-teardown.js index 67f229d2a..48f99aeb0 100644 --- a/plugins/model-apps/scripts/lib/sdk-teardown.js +++ b/plugins/model-apps/scripts/lib/sdk-teardown.js @@ -178,10 +178,19 @@ const KIND_HANDLERS = { // removed AND published, or when the app+sitemap are deleted outright (which is what the step // before this one just did). // - // So the delete IS the check. Attempting it and reading the platform's answer is strictly better - // than a pre-flight scan: it is authoritative (the platform's own dependency graph, not our model - // of it), it covers every surface the platform tracks rather than just sitemap XML, and it has no - // TOCTOU window — a pre-check can go stale between the check and the delete, this cannot. + // So the delete IS the check. Attempting it and reading the platform's answer beats a pre-flight + // scan: it is authoritative (the platform's own dependency graph, not our model of it), and it has + // no TOCTOU window — a pre-check can go stale between the check and the delete, this cannot. + // + // KNOWN GAP, measured rather than assumed: that graph covers SITEMAP references only. A page + // embedded in a FORM through the `MscrmControls.UxAgentControl` PCF is NOT tracked — a form was + // built with a page in its `RefId`, saved and published, and the page still reported ZERO + // dependents and deleted with a 204. The form's own required-components list names the PCF + // (component type 66) and never the page, because `RefId` is an opaque + // `static="true" type="SingleLine.Text"` value the platform cannot know is a reference. + // We accept that gap here because this step only ever deletes pages THIS build authored and is + // tearing down the app that owns them; it is not closable by asking the platform, and a formxml + // scan is the only thing that would close it. genpage: { // A page another app still references is a SKIP, not a failure — see isDependencyBlocked. tolerateDependencyBlock: true, From 708295a65347c09180afd2914b8f10c176eb48dc Mon Sep 17 00:00:00 2001 From: akshay-viz <181884695+akshay-viz@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:59:19 -0700 Subject: [PATCH 5/5] docs(model-apps): the project-delete cascade is now observed, not assumed The comment said the uxagentproject -> uxagentprojectfile relationship is Delete=Cascade on the strength of relationship metadata. That is now verified end to end: a page created via pac model genpage upload, with its four real file rows, was fully removed by a single DELETE of the project row - four cascaded, none orphaned. This was the last claim in this file resting on metadata rather than observation, and it is the claim the fix depends on: if the cascade did not fire, deleting only the project row would leak file rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 731c1111-d9b6-4fd7-b9d3-347f762ca7f5 --- plugins/model-apps/scripts/lib/sdk-teardown.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/model-apps/scripts/lib/sdk-teardown.js b/plugins/model-apps/scripts/lib/sdk-teardown.js index 48f99aeb0..9b48279be 100644 --- a/plugins/model-apps/scripts/lib/sdk-teardown.js +++ b/plugins/model-apps/scripts/lib/sdk-teardown.js @@ -231,7 +231,8 @@ const KIND_HANDLERS = { }, // Delete ONLY the project row. Its `uxagentprojectfile` children go with it: the // uxagentproject_uxagentprojectfile_uxagentprojectid relationship is CascadeConfiguration - // Delete=Cascade, so the platform removes them for us. + // Delete=Cascade, verified end to end — one DELETE of a real pac-created page removed the + // project and all four of its file rows, none orphaned. // // Deleting the files ourselves first would be actively DESTRUCTIVE. Dataverse tracks a // dependency on the PROJECT row (component type 10372) but NOT on its files (10373):