Skip to content

Commit e67cbf5

Browse files
authored
fix(model-apps): teardown owns generative-page deletion (#417)
* 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.
1 parent 478e4f4 commit e67cbf5

3 files changed

Lines changed: 338 additions & 30 deletions

File tree

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

Lines changed: 162 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
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
@@ -39,10 +46,10 @@
3946

4047
const { topoOrderEntities } = require('./_graph.js');
4148
const { 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');
4350
const { relationshipSchemaName, manyToManySchemaName, lookupColumnsFor, SDK_ROLE_MARKER, canonicalPersonaName, FORM_GUID_RE } = require('./app-spec.js');
4451
const { 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 /cannot be deleted because it is referenced by/.test(msg) || /referenced by \d+ other component/.test(msg);
93+
}
94+
7795
function 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.
496618
async 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;

plugins/model-apps/scripts/tests/sdk-surface-contract.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const SKILL_SDK_SURFACE = [
4949
'createWebResource',
5050
'deleteAppCascade',
5151
'deleteGlobalOptionSet',
52+
'deleteRecord',
5253
'deleteRelationship',
5354
'deleteRemoteArtifact',
5455
'deleteSecurityRole',

0 commit comments

Comments
 (0)