fix(model-apps): teardown owns generative-page deletion - #417
Conversation
There was a problem hiding this comment.
Pull request overview
Updates the model-apps plugin teardown pipeline to explicitly delete generative pages (uxagentproject + uxagentprojectfile) authored by the build, compensating for the vendored SDK’s deleteAppCascade no longer cascading those deletes.
Changes:
- Add a new teardown step (
genpage) immediately after the app delete, using the page manifest as the ownership authority and a cross-app sitemap scan to skip pages still referenced elsewhere. - Extend teardown logic to delete genpage file rows before the project row via
sdk.deleteRecord. - Update unit tests and the SDK surface contract to cover and enforce the new behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| plugins/model-apps/scripts/lib/sdk-teardown.js | Adds the genpage teardown step with manifest-scoped selection, cross-app safety scan, and ordered deletion (files then project). |
| plugins/model-apps/scripts/tests/sdk-teardown.test.js | Updates plan-shape assertions and adds a focused test suite covering genpage selection, ordering, safety/backoff, and failure modes. |
| plugins/model-apps/scripts/tests/sdk-surface-contract.test.js | Adds deleteRecord to the enforced vendored-SDK surface contract. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/lib/sdk-teardown.js:239
- genpage.del() deletes uxagentprojectfile rows in a loop, but if one file delete throws a not-found error (or other transient) the handler throws before attempting to delete the uxagentproject itself. Because deleteStep() treats any not-found as a successful delete of the whole item, this can incorrectly report the page as deleted while leaving the project row behind. Catch not-found per file delete and continue so the project delete still runs; rethrow non-not-found errors.
if (f && f.uxagentprojectfileid) {
await sdk.deleteRecord('uxagentprojectfile', f.uxagentprojectfileid);
}
}
await sdk.deleteRecord('uxagentproject', item.id);
plugins/model-apps/scripts/tests/sdk-teardown.test.js:450
- This assertion message is now inaccurate: deleteAppCascade no longer deletes generative pages, and genpage teardown is handled by a separate step. Updating the message will avoid misleading future failures/debugging.
assert.strictEqual(cascadeCalls[0].appModuleIdUnique, 'u-1', 'unique id passed (deleteAppCascade handles sitemap + genpage internally)');
fc10abc to
687727b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (5)
plugins/model-apps/scripts/lib/sdk-teardown.js:459
- The planTeardown comment mentions a “cross-app scan”, but no scan is performed; reference safety is enforced by Dataverse via the dependency-block error during deletion. The comment should be updated to reflect the actual mechanism.
// 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
plugins/model-apps/scripts/tests/sdk-teardown.test.js:1094
- This test configures otherApps/sitemap XML, but the current genpage handler doesn’t read appmodule/sitemap at all, so this setup doesn’t affect the assertion and is misleading. Simplify the setup so the test matches the behavior it’s actually verifying.
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: `<SiteMap><Area><Group><SubArea GenPageId="${PAGE_2}" /></Group></Area></SiteMap>` }],
plugins/model-apps/scripts/lib/sdk-teardown.js:17
- The header comment says the genpage step uses a “cross-app scan”, but the implementation delegates reference checks to Dataverse by attempting the delete and tolerating dependency-block errors. This comment is currently misleading and should be updated to match the actual behavior.
This issue also appears on line 455 of the same file.
// 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.
plugins/model-apps/scripts/lib/sdk-teardown.js:226
- This comment references isUndeletable, but dependency-block skips are handled by tolerateDependencyBlock + isDependencyBlocked. Keeping the reference to isUndeletable here is incorrect and makes it harder to understand the skip behavior.
// 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.
plugins/model-apps/scripts/tests/sdk-teardown.test.js:998
- There are two duplicate “A minimal SDK stand-in…” comment blocks here, and both refer to a cross-app sitemap scan that the genpage handler does not perform. This should be deduplicated and updated to describe the helper’s actual purpose.
This issue also appears on line 1090 of the same file.
// 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.
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
687727b to
4eb1632
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
plugins/model-apps/scripts/tests/sdk-teardown.test.js:1001
- This comment block is duplicated and refers to a "cross-app scan"/fetchAppsForPages behavior that doesn't exist in these genpage tests, making the helper harder to understand.
// 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
plugins/model-apps/scripts/tests/sdk-teardown.test.js:1098
- This test name claims it verifies deletion and cross-app references, but it only calls genpage.resolve and the otherApps sitemap data is never consulted. Renaming/removing the unused setup will avoid a false sense of coverage.
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: `<SiteMap><Area><Group><SubArea GenPageId="${PAGE_2}" /></Group></Area></SiteMap>` }],
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
Review comments addressed — one was a genuine data-loss bugThanks for the passes. The headline finding was correct and I'd have shipped it: the files-first delete order could gut a page the platform refuses to delete. I verified it against a live environment instead of reasoning from the code:
So the page row is protected; its content is not. Deleting files first stripped a referenced page bare moments before the platform refused to delete it. Because the relationship cascades, the file loop was never needed. The fix is a deletion: async del(sdk, item) {
await sdk.deleteRecord('uxagentproject', item.id);
}Every comment, and what changed
On test rigourBoth regression tests were verified red against the old ordering before being taken green. That mattered: my first skip test passed even with the bug reintroduced, because its mock returned no files. It now supplies files and asserts the page delete is the only write attempted.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
plugins/model-apps/scripts/lib/sdk-teardown.js:622
deleteStepnow treats dependency-blocked deletes asskippedIds, but downstream reporting assumesskippedIdsmeans “undeletable system/managed artifacts”. This makes the emitted summary misleading (it will say “undeletable” for a page that is simply still referenced) and the CLI step counts will mark the step as “✓ deleted” even when 0 records were deleted. Consider returning/propagating a distinct skip reason (e.g.,dependencyBlockedIdsvsundeletableIds, orskipped: [{id, reason}]) so logs can accurately report “referenced — skipped” and step status/counting can reflect a skip when appropriate.
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
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
Round 2 comment addressed — skip reasons are now distinct
Correct, and worth fixing — the two outcomes mean opposite things to whoever reads the run:
Collapsing them meant a teardown that correctly skipped a shared page printed
Verified by running the real Added a test per reason — On the step status half of your comment: I left the step as
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/tests/sdk-teardown.test.js:1128
- This test duplicates the earlier "genpage resolve returns only pages the manifest says WE authored" case (same manifest/live setup and same assertion). It would be more valuable to cover a distinct edge case (e.g., manifest entries with invalid/non-GUID pageIds are ignored).
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, PAGE_2],
});
plugins/model-apps/scripts/lib/sdk-teardown.js:83
- The comment block above
isDependencyBlockedcurrently describes “system/managed artifacts”, but the next function is actually dependency-block detection. This makes the intent harder to follow when scanning the helpers; consider splitting the comments so each helper documents its own behavior.
// Detect a system/managed artifact that Dataverse refuses to delete (e.g. the auto-generated
// "Active <Entity>" view whose name a spec view may reuse). It is not ours to remove, so a
// 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.
…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
Live-tested — and it closed the last open surface, with a bad answerAsked whether I'd live-tested this. I had measured the platform facts the fix relies on, but not the remaining unknown. Now done. The form surface is NOT dependency-trackedBuilt a form on
Why: So platform arbitration is authoritative for sitemaps and blind to forms. A page can be deleted out from under a published form with no objection. Not closable by asking the platform — and the cross-app sitemap scan I discarded wouldn't have closed it either, since the reference isn't in a sitemap. Does this change this PR? Only the comment.This step deletes only pages its own manifest authored, while tearing down the app that owns them — it never deletes on another owner's behalf. That was already the right position, and it happens to be the only one that stays correct for a surface the platform can't report. What was wrong was the wording: the comment claimed attempting the delete "covers every surface the platform tracks". Literally true, but it invited a stronger conclusion than the facts support. Now states the gap explicitly ( What I could NOT verifyThat deleting a Risk if the metadata were somehow wrong: orphaned file rows — a cleanliness problem, not data loss, and strictly better than the files-first order it replaces. Flagging it rather than implying I proved it.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
plugins/model-apps/scripts/tests/sdk-teardown.test.js:1128
- This test duplicates the earlier "genpage resolve returns only pages the manifest says WE authored" case (same manifestPages/livePages and same assertion), which adds runtime without increasing coverage. Consider replacing it with a distinct assertion (e.g., that non-GUID manifest pageIds are ignored) or removing it.
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, PAGE_2],
});
…umed 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
Cascade now observed end-to-end — the last assumption is goneI said earlier I couldn't verify that deleting the project row actually removes its files, because So Every claim behind this PR is now measured, not inferred. A methodology note worth sharingThe first run of that probe reported "0 files" — which would have looked like a clean negative result. It was actually a 401 on every request: I'd minted the Dataverse token from the corporate tenant. The probe did Same class of mistake as the vacuous skip test earlier in this PR: a check that cannot fail tells you nothing when it passes. Probes now assert Test org left at zero residue — every probe page, form and app cleaned up and verified.
|
Teardown owns generative-page deletion
cds-maker-sdk'sdeleteAppCascadeno longer deletes an app's generative pages. Auxagentprojectis referenced by an app, not owned by one — another app's sitemap can carry the sameGenPageId, and a form can embed the page via theMscrmControls.UxAgentControlPCF'sRefId, a reference stored in formxml that appears in no sitemap. The SDK reports them instead and leaves the decision to the caller.We are that caller — and we created the pages. Teardown now deletes them itself.
How
A new
genpagestep, ordered immediately after the app:Ownership from the page manifest, not the sitemap. The manifest web resource is the durable record of exactly which pages this build authored; the sitemap would also list pages someone else added. It's read before the web-resources phase deletes it.
Safety delegated to Dataverse. 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" — published or not. The dependency clears only once the subarea is removed and published, or the app and its sitemap are deleted outright (which the preceding step just did).
So the delete is the check. Attempting it and reading the platform's answer beats a pre-flight scan: authoritative rather than our model of the truth, covers every surface the platform tracks instead of just sitemap XML, and no TOCTOU window.
A dependency block is recorded as a skip for this kind (
tolerateDependencyBlock) — another app legitimately owning the page is an expected outcome, not a broken teardown. It stays a genuine failure for every other kind.Files are deleted before the project. Anything we cannot prove is ours — absent/unreadable manifest, failed existence query — deletes nothing.
Tests
Seven new cases: 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.
deleteRecordadded toSKILL_SDK_SURFACE— the surface-contract test correctly caught it.Full suite: 1443 pass, 0 fail.
Sequencing
Merge after the SDK change and the re-vendor. Compatible with the currently vendored bundle either way: that bundle still deletes pages in the cascade, so
resolvefinds nothing live and the step is a no-op.