Skip to content

Commit ca8caf3

Browse files
tyaginidhiclaude
andcommitted
Site-referenced table discovery + dependency-aware solution splitting
Replaces publisher-prefix table discovery (which over-counts catastrophically with a shared/default publisher — a 6-table site matched 22 unrelated tables — and misses real tables from a different prefix) with site-referenced scoping: the custom tables the site's `.powerpages-site/table-permissions/` (+ datamodel manifest) actually reference, intersected with the env's custom-unmanaged tables. SME-confirmed: table permissions are the complete signal. Replaces the old one-solution-per-table-name-stem split heuristic (which produced ~one solution per table — e.g. a 21-solution split) with a dependency-aware, capacity-bounded packer: union-find connected-component clusters over table relationships (lookups + N:N), then first-fit-decreasing bin-packing of whole clusters into the fewest solutions under maxTableCount/maxSchemaAttrs, capped at maxSchemaSplitSolutions (8). The split trigger + thresholds are unchanged — only the packing. New shared libs: - resolve-site-tables.js — site-referenced table scoping (single source of truth) - query-metadata.js — consolidated custom-unmanaged-table query - query-table-relationships.js — relationship edges (lib; audit-permissions CLI is now a thin wrapper) - validation-helpers.js — odataGet/odataGetAll shared paginator estimate-solution-size.js now emits tableCountScope + tableRelationships[]; compute-split-plan.js consumes the edges. setup-solution Phase 5.2.D uses the shared discovery helper. 1209 tests pass, alm-lint 0 findings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59c2497 commit ca8caf3

20 files changed

Lines changed: 984 additions & 125 deletions

plugins/power-pages/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "power-pages",
3-
"version": "2.3.0",
3+
"version": "2.4.0",
44
"description": "Create and deploy Power Pages sites using modern development approaches. Supports code sites (SPAs) with React, Angular, Vue, or Astro. Includes ALM orchestration (plan-alm) with a solution-splitting decision tree, per-solution pipelines, Azure Blob asset advisory, manifest schema v2 for multi-solution deployments, and force-link remediation for cross-host pipeline migrations.",
55
"author": {
66
"name": "Microsoft",

plugins/power-pages/AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,12 @@ Shared lib modules live at `scripts/lib/` and are imported by other scripts via
204204
#### Solution Splitting Decision Tree (v1.3.0+)
205205

206206
- `scripts/lib/alm-thresholds.js`: Central default threshold constants for the split decision tree. Loads optional `.alm-config.json` from project root and merges over defaults. Exports `DEFAULTS`, `DEFAULT_CONFIG`, `loadConfig(projectRoot)`, `classifyTier(value, greenUpperExclusive, yellowUpperExclusive)`, `deepMerge(target, source)`. Used by `estimate-solution-size.js` and `compute-split-plan.js`.
207-
- `scripts/lib/estimate-solution-size.js`: Estimates solution size + component counts by querying Dataverse. Args: `--envUrl`, `--websiteRecordId`, `--token` (opt), `--publisherPrefix` (opt), `--siteName` (opt), `--solutionId` (opt — scopes env var count to the target solution; without it falls back to a publisher-prefix tenant-wide query that overcounts when prefix is shared), `--datamodelManifest` (opt), `--projectRoot` (opt — enables disk cross-check: walks the local build-output directory (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces the byte total). Output: `{ totalSizeMB, componentCountSiteTotal, componentCountSiteActionable, componentCountInSolution, orphansOnSite, tableCount, schemaAttrCount, webFilesAggregateMB, webFilesIndividual[], webFileCount, webFileSampleSize, webFilesDiskMeasuredMB, webFilesDiskMeasuredPath, webFilesDiskFileCount, cloudFlowCount, botCount, envVarCount, envVarCountScope, envVarCountTenantWide, mediaRatio, siteType, tables[], breakdown, estimationMethod, estimationAccuracyPct, truncationSuspected, truncationWarnings, ppcGroundTruthCount }`. Metadata-based estimation with ±15% caveat. Web-file size is measured via stratified sample (first 50 + middle 50 + last 50, cap 150) scaled to full count. Disk fields are null unless `--projectRoot` was passed AND a build-output directory was found. Truncation canaries fire when Dataverse pagination disagrees with `@odata.count`, when ppcs land on a page-size boundary, when sampled average bytes/file < 1 KB at scale, or when the disk total exceeds the Dataverse total by >2× — any signal flips `truncationSuspected: true` with a per-cause `truncationWarnings[]` entry. Used by `plan-alm` Phase 1 Step 10.
208-
- `scripts/lib/compute-split-plan.js`: Runs the split decision tree against a size-estimate blob. Args: `--estimate <path>`, `--projectRoot` (opt — for `.alm-config.json` overrides), `--siteName` (opt), `--publisherPrefix` (opt). Output: `{ sizeAnalysis, assetAdvisory, splitStrategy, appliedStrategies, compositeSubPartitioned, proposedSolutions[], recommendations[], truncationSuspected, truncationWarnings }`. Evaluates strategies in priority order: Strategy 3 (Schema Segmentation) → Strategy 1 (Layer Split) → Strategy 2 (Change-Frequency) → Strategy 4 (Config Isolation). Strategy 4 stacks additively. Strategy 1 also runs a composite sub-partition pass: when Core still exceeds the size OR component-count cap after Web Assets are peeled off, Core is replaced with change-frequency-shaped sub-children (`_Foundation`/`_Config`/`_Content`, plus `_Integration` whenever the parent had any flows or bots — coverage takes priority over the `changeFreqMinFlows` heuristic, which governs only the TOP-LEVEL strategy choice). When additive Strategy 4 is firing concurrently (a top-level `_EnvVars` solution), `_Config` drops `Environment Variable` from its componentTypes to avoid double-claim; when it isn't, `_Config` absorbs env vars so they have an owner. Sub-partitioning sets `compositeSubPartitioned: true` and appends `composite-sub-partition` to `appliedStrategies`. `validateSplits` checks BOTH the size AND component-count cap per split (skipping `isFutureBuffer` solutions). Supports `.alm-config.json` overrides including `strategyOverride` to bypass the tree. See `solution-splitting-logic.md` spec in design docs for full logic.
207+
- `scripts/lib/estimate-solution-size.js`: Estimates solution size + component counts by querying Dataverse. Args: `--envUrl`, `--websiteRecordId`, `--token` (opt), `--publisherPrefix` (opt), `--siteName` (opt), `--solutionId` (opt — scopes env var count to the target solution; without it falls back to a publisher-prefix tenant-wide query that overcounts when prefix is shared), `--datamodelManifest` (opt), `--projectRoot` (opt — enables disk cross-check: walks the local build-output directory (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces the byte total). Output: `{ totalSizeMB, componentCountSiteTotal, componentCountSiteActionable, componentCountInSolution, orphansOnSite, tableCount, tableCountScope, schemaAttrCount, webFilesAggregateMB, webFilesIndividual[], webFileCount, webFileSampleSize, webFilesDiskMeasuredMB, webFilesDiskMeasuredPath, webFilesDiskFileCount, cloudFlowCount, botCount, envVarCount, envVarCountScope, envVarCountTenantWide, mediaRatio, siteType, tables[], tableRelationships[], breakdown, estimationMethod, estimationAccuracyPct, truncationSuspected, truncationWarnings, ppcGroundTruthCount }`. **Table discovery is site-referenced, NOT publisher-prefix:** `tableCount`/`tables[]` are scoped to the custom tables the site actually references — its `.powerpages-site/table-permissions/` (+ datamodel manifest) intersected with the env's custom-unmanaged tables (via `resolve-site-tables.js` + `query-metadata.js`). `tableCountScope` ∈ `"site-referenced" | "manifest-only" | "unavailable"` (the last → 0 tables, never an env-wide prefix dump). `--publisherPrefix` now scopes ONLY the env var count, not tables. `tableRelationships[]` are `[a,b]` dependency edges (lookups + N:N, via `query-table-relationships.js`) among the scoped tables, consumed by `compute-split-plan.js` to cluster related tables into the same solution. Metadata-based estimation with ±15% caveat. Web-file size is measured via stratified sample (first 50 + middle 50 + last 50, cap 150) scaled to full count. Disk fields are null unless `--projectRoot` was passed AND a build-output directory was found. Truncation canaries fire when Dataverse pagination disagrees with `@odata.count`, when ppcs land on a page-size boundary, when sampled average bytes/file < 1 KB at scale, or when the disk total exceeds the Dataverse total by >2× — any signal flips `truncationSuspected: true` with a per-cause `truncationWarnings[]` entry. Used by `plan-alm` Phase 1 Step 10.
208+
- `scripts/lib/compute-split-plan.js`: Runs the split decision tree against a size-estimate blob. Args: `--estimate <path>`, `--projectRoot` (opt — for `.alm-config.json` overrides), `--siteName` (opt), `--publisherPrefix` (opt). Output: `{ sizeAnalysis, assetAdvisory, splitStrategy, appliedStrategies, compositeSubPartitioned, proposedSolutions[], recommendations[], truncationSuspected, truncationWarnings }`. Evaluates strategies in priority order: Strategy 3 (Schema Segmentation) → Strategy 1 (Layer Split) → Strategy 2 (Change-Frequency) → Strategy 4 (Config Isolation). **Schema Segmentation is dependency-aware + capacity-bounded:** it builds connected-component clusters from `estimate.tableRelationships` (union-find), then bin-packs whole clusters (never splitting a relationship) into the fewest solutions that keep each under `maxTableCount`/`maxSchemaAttrs`, capped at `maxSchemaSplitSolutions` (default 8). This replaced the old one-solution-per-table-name-stem heuristic that produced ~one solution per table. An indivisible cluster over the cap stays whole and raises an oversized-cluster `recommendations[]` warning. The split trigger + thresholds are unchanged — only the packing. Strategy 4 stacks additively. Strategy 1 also runs a composite sub-partition pass: when Core still exceeds the size OR component-count cap after Web Assets are peeled off, Core is replaced with change-frequency-shaped sub-children (`_Foundation`/`_Config`/`_Content`, plus `_Integration` whenever the parent had any flows or bots — coverage takes priority over the `changeFreqMinFlows` heuristic, which governs only the TOP-LEVEL strategy choice). When additive Strategy 4 is firing concurrently (a top-level `_EnvVars` solution), `_Config` drops `Environment Variable` from its componentTypes to avoid double-claim; when it isn't, `_Config` absorbs env vars so they have an owner. Sub-partitioning sets `compositeSubPartitioned: true` and appends `composite-sub-partition` to `appliedStrategies`. `validateSplits` checks BOTH the size AND component-count cap per split (skipping `isFutureBuffer` solutions). Supports `.alm-config.json` overrides including `strategyOverride` to bypass the tree. See `solution-splitting-logic.md` spec in design docs for full logic.
209+
- `scripts/lib/resolve-site-tables.js`: Single source of truth for "which custom tables does this site actually use." `collectReferencedEntityNames({ projectRoot, datamodelManifestPath })` reads `.powerpages-site/table-permissions/*.tablepermission.yml` (`entitylogicalname`, via `powerpages-config.js → loadTablePermissions`) + the datamodel manifest → `{ names:Set, available, sources }`. `scopeCustomTables(referencedNames, customUnmanagedTables)` intersects that set with the env's custom-unmanaged tables. SME-confirmed: table permissions are the complete signal ("if a table is used in the site there will be permissions for it"), so forms/lists are NOT scanned. Used by `estimate-solution-size.js` and `discover-site-components.js` to replace the publisher-prefix table dump.
210+
- `scripts/lib/query-metadata.js`: `queryCustomUnmanagedTables(envUrl, token, makeRequest?)``[{ logicalName, metadataId, schemaName, displayName }]` (the single `EntityDefinitions?$filter=IsCustomEntity` query, `IsManaged===false` filtered). Consolidates the formerly-triplicated custom-table query (estimator, discover-site-components, setup-solution). Reuses `odataGetAll` from `validation-helpers.js`.
211+
- `scripts/lib/query-table-relationships.js`: `fetchTableRelationships(envUrl, table, token, makeRequest?)``{ oneToMany[], manyToMany[] }`. Extracted from `skills/audit-permissions/scripts/query-table-relationships.js` (now a thin CLI wrapper over this lib) and extended with ManyToMany. OneToMany errors propagate; ManyToMany is best-effort. Used by the estimator to build `tableRelationships[]` and by audit-permissions for relationship-scope validation.
212+
- `scripts/lib/validation-helpers.js` also exports `odataGet(url, token, makeRequest?)` + `odataGetAll(url, token, makeRequest?, maxPages?)` — the shared, injectable OData GET + `@odata.nextLink` pagination used by the new metadata/relationship helpers (avoids each lib rolling its own paginator).
209213

210214
#### Solution Management
211215

plugins/power-pages/scripts/lib/alm-thresholds.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ const DEFAULTS = Object.freeze({
2121
hardFlagComponentCount: 10000,
2222
maxSchemaAttrs: 15000,
2323
maxTableCount: 20,
24+
// Safety ceiling on the number of auto-derived schema-split solutions. The
25+
// schema-segmentation packing keeps each solution under maxTableCount /
26+
// maxSchemaAttrs, but caps the COUNT here so a pathological schema can't
27+
// explode into dozens of solutions — beyond this, the hardFlagComponentCount
28+
// recommendation tells the user to archive/consolidate instead.
29+
maxSchemaSplitSolutions: 8,
2430
maxAggregateWebFilesMB: 40,
2531
maxSingleFileMB: 2,
2632
maxEnvVarCount: 500,

plugins/power-pages/scripts/lib/compute-split-plan.js

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ function partitionByChangeFrequency(estimate, meta) {
299299
function partitionBySchema(estimate, meta, config) {
300300
const explicitDomains = Array.isArray(config.domains) && config.domains.length > 0
301301
? config.domains
302-
: deriveDomainsFromPrefix(estimate);
302+
: deriveDomainsByCapacity(estimate, config.thresholds);
303303

304304
// Derive domain vs site size shares from the estimator's breakdown when available,
305305
// falling back to a 50/50 heuristic only if breakdown is absent.
@@ -319,9 +319,12 @@ function partitionBySchema(estimate, meta, config) {
319319
componentTypes: ['Table'],
320320
description: `Schema domain: ${dom.name}. Tables: ${(dom.tableLogicalNames || []).join(', ') || '(derived)'}${domainDescSuffix}`,
321321
sizeMB: round(sizePerDomain),
322-
componentCount: Math.ceil(
323-
(estimate.schemaAttrCount || 0) / domainCount,
324-
),
322+
// A Table domain's component count IS its table count when known (each table
323+
// is one Entity solution component). Falls back to an even attr-share split
324+
// only for explicit domains that didn't list their tables.
325+
componentCount: (dom.tableLogicalNames && dom.tableLogicalNames.length > 0)
326+
? dom.tableLogicalNames.length
327+
: Math.ceil((estimate.schemaAttrCount || 0) / domainCount),
325328
components: [],
326329
tableLogicalNames: dom.tableLogicalNames || [],
327330
}));
@@ -345,23 +348,99 @@ function partitionBySchema(estimate, meta, config) {
345348
return [...domainSolutions, siteSolution];
346349
}
347350

348-
function deriveDomainsFromPrefix(estimate) {
349-
const tables = estimate.tables || [];
350-
if (tables.length === 0) return [{ name: 'All', tableLogicalNames: [] }];
351+
// --- Dependency-aware schema packing ---------------------------------------
352+
//
353+
// Replaces the old "one solution per table-name stem" heuristic (which produced
354+
// ~one solution per table for any distinctly-named schema). Tables connected by
355+
// a relationship MUST ship together, so we:
356+
// 1. Group tables into connected components (union-find over the estimator's
357+
// `tableRelationships` edges). Because components have no edges between
358+
// them, packing whole components into separate solutions never cuts a
359+
// relationship — so there are no cross-/circular-solution table deps and
360+
// import order among the table solutions is irrelevant.
361+
// 2. Bin-pack the components into the FEWEST solutions that keep each under the
362+
// per-solution caps (maxTableCount tables AND maxSchemaAttrs columns),
363+
// capped at maxSchemaSplitSolutions.
364+
365+
function normalizeTables(estimate) {
366+
return (estimate.tables || [])
367+
.map((t) => ({
368+
logicalName: (t && (t.logicalName || t)).toString(),
369+
attributeCount: (t && t.attributeCount) || 0,
370+
}))
371+
.filter((t) => t.logicalName);
372+
}
351373

374+
// Union-find over tables + relationship edges -> array of clusters (each a list
375+
// of table objects). A table with no edges is its own singleton cluster.
376+
function buildTableClusters(tables, edges) {
377+
const idx = new Map();
378+
tables.forEach((t, i) => idx.set(t.logicalName.toLowerCase(), i));
379+
const parent = tables.map((_, i) => i);
380+
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
381+
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
382+
for (const e of edges || []) {
383+
if (!Array.isArray(e) || e.length < 2) continue;
384+
const ia = idx.get(String(e[0]).toLowerCase());
385+
const ib = idx.get(String(e[1]).toLowerCase());
386+
if (ia != null && ib != null) union(ia, ib);
387+
}
352388
const groups = new Map();
353-
for (const t of tables) {
354-
const name = (t.logicalName || t).toString();
355-
const afterPrefix = name.includes('_') ? name.split('_').slice(1).join('_') : name;
356-
const stem = afterPrefix.split(/[_]/)[0] || 'misc';
357-
const key = stem.charAt(0).toUpperCase() + stem.slice(1);
358-
if (!groups.has(key)) groups.set(key, []);
359-
groups.get(key).push(name);
389+
tables.forEach((t, i) => {
390+
const r = find(i);
391+
if (!groups.has(r)) groups.set(r, []);
392+
groups.get(r).push(t);
393+
});
394+
return [...groups.values()];
395+
}
396+
397+
function clusterAttrs(cluster) {
398+
return cluster.reduce((s, t) => s + (t.attributeCount || 0), 0);
399+
}
400+
401+
// First-fit-decreasing pack of whole clusters into `n` buckets, respecting the
402+
// per-solution table + attribute caps. A cluster that fits nowhere under the
403+
// caps (oversized, or n too small) goes to the least-loaded bucket — that bucket
404+
// then exceeds a cap and is surfaced by the oversized-cluster recommendation.
405+
function packClusters(clusters, n, thresholds) {
406+
const sorted = [...clusters].sort((a, b) => (clusterAttrs(b) - clusterAttrs(a)) || (b.length - a.length));
407+
const buckets = Array.from({ length: Math.max(n, 1) }, () => ({ tables: [], attrs: 0 }));
408+
for (const cluster of sorted) {
409+
const cAttrs = clusterAttrs(cluster);
410+
let target = buckets.findIndex(
411+
(b) => b.tables.length + cluster.length <= thresholds.maxTableCount &&
412+
b.attrs + cAttrs <= thresholds.maxSchemaAttrs,
413+
);
414+
if (target === -1) {
415+
target = buckets.reduce((best, b, i) => (b.attrs < buckets[best].attrs ? i : best), 0);
416+
}
417+
buckets[target].tables.push(...cluster);
418+
buckets[target].attrs += cAttrs;
360419
}
420+
return buckets.filter((b) => b.tables.length > 0);
421+
}
361422

362-
return Array.from(groups.entries()).map(([name, tableLogicalNames]) => ({
363-
name,
364-
tableLogicalNames,
423+
// Returns capacity-bounded "domains" (one per packed bucket) in the same shape
424+
// the schema partitioner consumes: { name, tableLogicalNames }.
425+
function deriveDomainsByCapacity(estimate, thresholds) {
426+
const tables = normalizeTables(estimate);
427+
if (tables.length === 0) return [{ name: 'Tables', tableLogicalNames: [] }];
428+
429+
const clusters = buildTableClusters(tables, estimate.tableRelationships || []);
430+
const totalAttrs = tables.reduce((s, t) => s + t.attributeCount, 0);
431+
const ceiling = (thresholds && thresholds.maxSchemaSplitSolutions) || 8;
432+
let n = Math.max(
433+
1,
434+
Math.ceil(tables.length / thresholds.maxTableCount),
435+
Math.ceil(totalAttrs / Math.max(thresholds.maxSchemaAttrs, 1)),
436+
);
437+
n = Math.min(n, ceiling, clusters.length);
438+
439+
const buckets = packClusters(clusters, n, thresholds);
440+
const multi = buckets.length > 1;
441+
return buckets.map((b, i) => ({
442+
name: multi ? `Tables ${i + 1}` : 'Tables',
443+
tableLogicalNames: b.tables.map((t) => t.logicalName),
365444
}));
366445
}
367446

@@ -704,6 +783,17 @@ function computeSplitPlan({ estimate, config, meta }) {
704783
proposedSolutions = appendFutureBuffer(proposedSolutions, meta);
705784

706785
const splitWarnings = validateSplits(proposedSolutions, config.thresholds);
786+
// Oversized-cluster guard: a Table solution holding more tables than the
787+
// per-solution cap means a single connected dependency cluster couldn't be
788+
// split without cutting a relationship. Name it so the user can decide whether
789+
// to denormalize the schema or raise the cap — we never silently split a cluster.
790+
const oversizedClusterWarnings = proposedSolutions
791+
.filter((s) => Array.isArray(s.tableLogicalNames) &&
792+
s.tableLogicalNames.length > config.thresholds.maxTableCount)
793+
.map((s) => ({
794+
type: 'warning',
795+
message: `Solution ${s.uniqueName} holds ${s.tableLogicalNames.length} related tables — above the ${config.thresholds.maxTableCount}-per-solution cap — because they form one dependency cluster that cannot be split without breaking a relationship. Consider denormalizing the schema or raising maxTableCount in .alm-config.json.`,
796+
}));
707797
// Surface estimator-side truncation warnings as `recommendations[]` entries
708798
// so the rendered plan shows them inline. These get the `error` type because
709799
// a truncated input is more dangerous than a normal split-decision warning
@@ -715,7 +805,8 @@ function computeSplitPlan({ estimate, config, meta }) {
715805
}));
716806
const recommendations = truncationRecs
717807
.concat(buildRecommendations(estimate, strategy, config))
718-
.concat(splitWarnings);
808+
.concat(splitWarnings)
809+
.concat(oversizedClusterWarnings);
719810

720811
const appliedStrategies = [strategy.primary];
721812
if (strategy.additive) appliedStrategies.push('strategy-4-config-isolation');

0 commit comments

Comments
 (0)